feat: KYC risk profiling, AML flagged transactions bulk review, Risk Review report & Board Resolution register
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, Inject, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
|
||||
import { AmlService } from 'app/aml/aml.service';
|
||||
|
||||
const CATEGORY_LABEL: Record<number, string> = { 1: 'Low', 2: 'Medium', 3: 'High' };
|
||||
|
||||
/**
|
||||
* The approval queue for risk-category changes raised from the Risk Review Report.
|
||||
*
|
||||
* <p>
|
||||
* Approving here moves the customer's KYC rating and their re-KYC due date together, so the row
|
||||
* shows where the rating is going from and to, and why the reviewer asked - the checker is deciding
|
||||
* how closely the bank watches that customer, not clearing a queue item.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-risk-change-approvals',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule
|
||||
],
|
||||
template: `
|
||||
<div class="rca-root">
|
||||
<div class="rca-head">
|
||||
<div>
|
||||
<h2 class="rca-title">Risk Category Approvals</h2>
|
||||
<p class="rca-sub">
|
||||
Changes to a customer's KYC risk category, raised from the Risk Review Report. Approving moves the rating
|
||||
and the re-KYC due date together, and writes the reason into the customer's risk history.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rca-filters">
|
||||
<button
|
||||
*ngFor="let s of statuses"
|
||||
type="button"
|
||||
class="rca-chip"
|
||||
[class.rca-chip--on]="selectedStatus === s"
|
||||
(click)="filterBy(s)"
|
||||
>
|
||||
{{ s === '' ? 'All' : s }} <span class="rca-count">({{ countOf(s) }})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rca-table-wrap">
|
||||
<table class="rca-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Req. ID</th>
|
||||
<th>Customer</th>
|
||||
<th>CIF</th>
|
||||
<th>Branch</th>
|
||||
<th>Rating change</th>
|
||||
<th>Reason</th>
|
||||
<th>Status</th>
|
||||
<th>Requested By</th>
|
||||
<th>Requested On</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let r of filtered">
|
||||
<td class="mono">{{ r.id }}</td>
|
||||
<td>
|
||||
<strong>{{ r.clientName }}</strong>
|
||||
</td>
|
||||
<td class="mono">{{ r.cifNumber || '—' }}</td>
|
||||
<td>{{ r.officeName || '—' }}</td>
|
||||
<td>
|
||||
<span class="rca-move">
|
||||
{{ labelOf(r.currentCategory) }} <span class="rca-arrow">→</span>
|
||||
<strong>{{ labelOf(r.requestedCategory) }}</strong>
|
||||
</span>
|
||||
</td>
|
||||
<td class="rca-reason">{{ r.reason }}</td>
|
||||
<td>
|
||||
<span class="rca-status" [ngClass]="'rca-status--' + (r.status || '').toLowerCase()">{{ r.status }}</span>
|
||||
</td>
|
||||
<td>{{ r.makerUsername }}</td>
|
||||
<td class="mono">{{ r.createdDate }}</td>
|
||||
<td>
|
||||
<div class="rca-actions" *ngIf="r.status === 'PENDING'">
|
||||
<button mat-flat-button color="primary" type="button" (click)="approve(r)">Approve</button>
|
||||
<button mat-stroked-button color="warn" type="button" (click)="reject(r)">Reject</button>
|
||||
</div>
|
||||
<span class="rca-done" *ngIf="r.status === 'APPROVED'">
|
||||
by {{ r.checkerUsername }}, {{ r.checkerDate }}
|
||||
</span>
|
||||
<span class="rca-done" *ngIf="r.status === 'REJECTED'">{{ r.rejectionReason }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="!filtered.length">
|
||||
<td colspan="10" class="rca-empty">No risk category change matches this filter.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.rca-root {
|
||||
padding: 20px 24px 40px;
|
||||
background: #f8fafc;
|
||||
min-height: 100%;
|
||||
}
|
||||
.rca-head {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.rca-title {
|
||||
margin: 0 0 3px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
.rca-sub {
|
||||
margin: 0;
|
||||
max-width: 800px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
}
|
||||
.rca-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rca-chip {
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rca-chip--on {
|
||||
border-color: #1e3a8a;
|
||||
background: #eff6ff;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
.rca-count {
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
.rca-table-wrap {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 11px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.rca-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.rca-table th {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
background: #f1f5f9;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.rca-table td {
|
||||
padding: 11px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
color: #334155;
|
||||
}
|
||||
.mono {
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
.rca-move {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rca-arrow {
|
||||
color: #94a3b8;
|
||||
margin: 0 4px;
|
||||
}
|
||||
.rca-reason {
|
||||
max-width: 280px;
|
||||
}
|
||||
.rca-status {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
.rca-status--pending {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
.rca-status--approved {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
.rca-status--rejected {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.rca-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.rca-done {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
.rca-empty {
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class RiskChangeApprovalsComponent implements OnInit {
|
||||
private amlService = inject(AmlService);
|
||||
private dialog = inject(MatDialog);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
rows: any[] = [];
|
||||
readonly statuses = ['PENDING', 'APPROVED', 'REJECTED', ''];
|
||||
selectedStatus = 'PENDING';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.amlService.getRiskChangeRequests().subscribe({
|
||||
next: (rows) => (this.rows = rows || []),
|
||||
error: (err: any) =>
|
||||
this.snackBar.open('Could not load requests: ' + (err?.error?.defaultUserMessage || err.message), 'Close', {
|
||||
duration: 5000
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
get filtered(): any[] {
|
||||
return this.selectedStatus ? this.rows.filter((r) => r.status === this.selectedStatus) : this.rows;
|
||||
}
|
||||
|
||||
countOf(status: string): number {
|
||||
return status ? this.rows.filter((r) => r.status === status).length : this.rows.length;
|
||||
}
|
||||
|
||||
filterBy(status: string): void {
|
||||
this.selectedStatus = status;
|
||||
}
|
||||
|
||||
labelOf(category: number | null): string {
|
||||
return category == null ? 'Unrated' : CATEGORY_LABEL[category] || '—';
|
||||
}
|
||||
|
||||
approve(r: any): void {
|
||||
this.amlService.approveRiskChange(r.id).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open(
|
||||
`${r.clientName} moved to ${this.labelOf(r.requestedCategory)} — re-KYC due date updated.`,
|
||||
'Close',
|
||||
{ duration: 6000 }
|
||||
);
|
||||
this.load();
|
||||
},
|
||||
error: (err: any) =>
|
||||
this.snackBar.open('Error: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { duration: 7000 })
|
||||
});
|
||||
}
|
||||
|
||||
reject(r: any): void {
|
||||
this.dialog
|
||||
.open(RejectRiskChangeDialogComponent, { width: '450px', data: { request: r }, disableClose: true })
|
||||
.afterClosed()
|
||||
.subscribe((done) => {
|
||||
if (done) {
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── REJECT DIALOG ──
|
||||
@Component({
|
||||
selector: 'mifosx-reject-risk-change-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Reject Risk Category Change</h2>
|
||||
<mat-dialog-content>
|
||||
<p class="hint">
|
||||
{{ data.request.clientName }} stays on their current rating. The reason is shown to the reviewer who raised it.
|
||||
</p>
|
||||
<form [formGroup]="form">
|
||||
<mat-form-field appearance="outline" class="full">
|
||||
<mat-label>Rejection reason</mat-label>
|
||||
<textarea matInput formControlName="rejectionReason" rows="3"></textarea>
|
||||
<mat-error *ngIf="form.get('rejectionReason')?.hasError('required')">A reason is required</mat-error>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
|
||||
<button mat-flat-button color="warn" type="button" [disabled]="form.invalid || busy" (click)="submit()">
|
||||
Reject
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.full {
|
||||
width: 100%;
|
||||
}
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class RejectRiskChangeDialogComponent {
|
||||
private fb = inject(FormBuilder);
|
||||
private amlService = inject(AmlService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
dialogRef = inject(MatDialogRef<RejectRiskChangeDialogComponent>);
|
||||
|
||||
form: FormGroup;
|
||||
busy = false;
|
||||
|
||||
constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) {
|
||||
this.form = this.fb.group({ rejectionReason: ['', Validators.required] });
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.amlService.rejectRiskChange(this.data.request.id, this.form.value.rejectionReason).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.snackBar.open('Risk category change rejected', 'Close', { duration: 3000 });
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.busy = false;
|
||||
this.snackBar.open('Error: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, Inject, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
|
||||
import { AmlService } from 'app/aml/aml.service';
|
||||
|
||||
export interface RiskChangeDialogData {
|
||||
clientId: number;
|
||||
customerName: string;
|
||||
/** 1 Low, 2 Medium, 3 High; null where the customer carries no rating yet. */
|
||||
currentCategory: number | null;
|
||||
/** What the report's own computation says the rating should be, used as the default. */
|
||||
recommendedCategory: number | null;
|
||||
/** The one-line explanation the report already shows against the row. */
|
||||
recommendationReason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises a change to a customer's KYC risk category for approval.
|
||||
*
|
||||
* <p>
|
||||
* The rating governs how often the customer is re-KYC'd and what AML thresholds they are measured
|
||||
* against, so it is not something one officer moves on their own. The dialog collects what it
|
||||
* should become and why; a checker applies it.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-risk-change-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Change Risk Category</h2>
|
||||
<mat-dialog-content>
|
||||
<div class="rc-summary">
|
||||
<div class="rc-row">
|
||||
<span class="rc-label">Customer</span>
|
||||
<strong class="rc-value">{{ data.customerName }}</strong>
|
||||
</div>
|
||||
<div class="rc-row">
|
||||
<span class="rc-label">Rating on file</span>
|
||||
<strong class="rc-value">{{ labelOf(data.currentCategory) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The report already worked this out; showing it saves the reviewer re-deriving it, and
|
||||
says plainly that the figure is a recommendation rather than a decision. -->
|
||||
<p class="rc-reason" *ngIf="data.recommendationReason">
|
||||
<span class="rc-label">Why this came up</span><br />{{ data.recommendationReason }}
|
||||
</p>
|
||||
|
||||
<form [formGroup]="form" class="rc-form">
|
||||
<mat-form-field appearance="outline" class="rc-full">
|
||||
<mat-label>New risk category</mat-label>
|
||||
<mat-select formControlName="requestedCategory">
|
||||
<mat-option [value]="1">Low</mat-option>
|
||||
<mat-option [value]="2">Medium</mat-option>
|
||||
<mat-option [value]="3">High</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="form.get('requestedCategory')?.hasError('required')">Pick the new category</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rc-full">
|
||||
<mat-label>Reason</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="reason"
|
||||
rows="3"
|
||||
placeholder="e.g. Repeated large cash deposits inconsistent with declared income"
|
||||
></textarea>
|
||||
<mat-hint>Recorded against the customer's risk history</mat-hint>
|
||||
<mat-error *ngIf="form.get('reason')?.hasError('required')">A reason is required</mat-error>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
|
||||
<p class="rc-effect">
|
||||
The rating does not move now. It goes to a checker, and on approval the customer's KYC record and re-KYC due
|
||||
date are updated together.
|
||||
</p>
|
||||
|
||||
<p class="rc-error" *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="form.invalid || submitting" (click)="submit()">
|
||||
{{ submitting ? 'Sending…' : 'Send for Approval' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.rc-summary {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.rc-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.rc-label {
|
||||
color: #64748b;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.rc-value {
|
||||
color: #1e293b;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.rc-reason {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #334155;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.rc-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.rc-full {
|
||||
width: 100%;
|
||||
}
|
||||
.rc-effect {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: #1e3a8a;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.rc-error {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class RiskChangeDialogComponent {
|
||||
private fb = inject(FormBuilder);
|
||||
private amlService = inject(AmlService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
dialogRef = inject(MatDialogRef<RiskChangeDialogComponent>);
|
||||
|
||||
form: FormGroup;
|
||||
submitting = false;
|
||||
errorMessage = '';
|
||||
|
||||
constructor(@Inject(MAT_DIALOG_DATA) public data: RiskChangeDialogData) {
|
||||
this.form = this.fb.group({
|
||||
// Defaulted to what the report recommends, but left editable: the reviewer has seen the
|
||||
// customer and the computation has not.
|
||||
requestedCategory: [
|
||||
data.recommendedCategory ?? null,
|
||||
Validators.required
|
||||
],
|
||||
reason: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
labelOf(category: number | null): string {
|
||||
return category === 1 ? 'Low' : category === 2 ? 'Medium' : category === 3 ? 'High' : 'Unrated';
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.errorMessage = '';
|
||||
const v = this.form.value;
|
||||
this.amlService.requestRiskChange(this.data.clientId, v.requestedCategory, (v.reason || '').trim()).subscribe({
|
||||
next: () => {
|
||||
this.submitting = false;
|
||||
this.snackBar.open(
|
||||
`Risk change to ${this.labelOf(v.requestedCategory)} sent for approval — ${this.data.customerName}`,
|
||||
'Close',
|
||||
{ duration: 5000 }
|
||||
);
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.submitting = false;
|
||||
this.errorMessage = err?.error?.defaultUserMessage || err?.error?.message || 'Could not send the request.';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -372,13 +372,29 @@ export const MATERIALITY = {
|
||||
* Rules whose seriousness does not depend on the amount. A sanctions match or a
|
||||
* deliberate structuring pattern is grave at any value, so activity being small
|
||||
* is not a reason to bring the rating down.
|
||||
*
|
||||
* Matched as prefixes, not as whole codes. The rule set names these by variant —
|
||||
* STRUCTURING_BELOW_50K, STRUCTURING_BELOW_10L, PEP_ACCOUNT_MONITORING — so an
|
||||
* exact-match Set never matched any of them and the protection was dead: a
|
||||
* customer caught structuring could be recommended for downgrade purely because
|
||||
* the amounts involved were small, which is the one case structuring exists to
|
||||
* catch.
|
||||
*/
|
||||
export const NON_MITIGABLE_RULES = new Set([
|
||||
'SANCTIONS_HIT',
|
||||
export const NON_MITIGABLE_RULE_PREFIXES = [
|
||||
'SANCTIONS',
|
||||
'PEP',
|
||||
'STRUCTURING',
|
||||
'HIGH_RISK_COUNTRY'
|
||||
]);
|
||||
'STRUCTURED',
|
||||
'HIGH_RISK_COUNTRY',
|
||||
'ADVERSE_MEDIA',
|
||||
'WILFUL_DEFAULTER'
|
||||
];
|
||||
|
||||
/** Whether a rule code names activity that stays grave however small the amounts are. */
|
||||
export function isNonMitigableRule(ruleCode: string): boolean {
|
||||
const code = (ruleCode || '').toUpperCase();
|
||||
return NON_MITIGABLE_RULE_PREFIXES.some((prefix) => code.startsWith(prefix));
|
||||
}
|
||||
|
||||
/** Shape of one element of GET /aml/alerts. */
|
||||
export interface LiveAmlAlert {
|
||||
|
||||
@@ -304,7 +304,9 @@
|
||||
reason sits under it rather than under the button, because it explains
|
||||
this conclusion and not the act of opening the KYC screen. -->
|
||||
<td class="rr-move-cell">
|
||||
<span class="rr-move" [class]="'rr-move--' + c.direction">
|
||||
<!-- An unrated customer is its own state: no rating exists, so "same" would be claiming the
|
||||
rating on file matches the behaviour when there is no rating on file. -->
|
||||
<span class="rr-move" [class]="'rr-move--' + (c.currentRisk ? c.direction : 'unrated')">
|
||||
<fa-icon [icon]="directionIcon(c)"></fa-icon>
|
||||
{{ directionText(c) }}
|
||||
</span>
|
||||
@@ -323,15 +325,32 @@
|
||||
<!-- Only the control. The verdict and its reason moved to their own column,
|
||||
so this no longer wraps to two lines under the button. -->
|
||||
<td class="rr-action-cell">
|
||||
<button
|
||||
type="button"
|
||||
class="rr-kyc-link"
|
||||
[class.rr-kyc-link--act]="c.direction !== 'same' && !c.escalated"
|
||||
(click)="openKyc(c, $event)"
|
||||
[attr.aria-label]="kycActionText(c) + ' for ' + c.customerName"
|
||||
>
|
||||
<fa-icon icon="id-card"></fa-icon> {{ kycActionText(c) }}
|
||||
</button>
|
||||
<!-- Raises the change for approval rather than sending the reviewer to the
|
||||
KYC screen to make it themselves. A case with the Principal Officer is
|
||||
held where it is until the STR concludes, so it only offers the record. -->
|
||||
@if (canChangeRisk(c)) {
|
||||
<button
|
||||
type="button"
|
||||
class="rr-kyc-link"
|
||||
[class.rr-kyc-link--act]="c.direction !== 'same'"
|
||||
(click)="changeRisk(c, $event)"
|
||||
[attr.aria-label]="'Change risk category for ' + c.customerName"
|
||||
>
|
||||
<fa-icon icon="shield-alt"></fa-icon> Change Risk
|
||||
</button>
|
||||
} @else {
|
||||
<!-- The rating cannot move while the STR is open, so the case itself is
|
||||
what this row can usefully offer. -->
|
||||
<button
|
||||
type="button"
|
||||
class="rr-kyc-link"
|
||||
(click)="openStrCase(c, $event)"
|
||||
title="The rating is held until the Principal Officer concludes the STR — open the case"
|
||||
[attr.aria-label]="'Open the STR case for ' + c.customerName"
|
||||
>
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> Open STR Case
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
@@ -937,6 +937,13 @@
|
||||
background: var(--r-g100);
|
||||
color: var(--r-g500);
|
||||
}
|
||||
|
||||
/* An unrated customer reads neither as settled nor as a movement: there is no rating to move. Amber says the row
|
||||
is outstanding without claiming the customer is a concern — the gap is the bank's, not theirs. */
|
||||
&--unrated {
|
||||
background: var(--r-amber-l, #fef3c7);
|
||||
color: var(--r-amber, #b45309);
|
||||
}
|
||||
}
|
||||
|
||||
.rr-move-why {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
|
||||
@@ -38,12 +39,14 @@ import {
|
||||
RiskLevel,
|
||||
channelLabel,
|
||||
mapLiveAlert,
|
||||
riskFromCategory
|
||||
riskFromCategory,
|
||||
riskRank
|
||||
} from './risk-review-data';
|
||||
import {
|
||||
DateRange,
|
||||
RatingDirection,
|
||||
RiskAlertView,
|
||||
BehaviourWindowByClient,
|
||||
RiskCustomerRow,
|
||||
RiskReviewFilter,
|
||||
RiskReviewModel,
|
||||
@@ -53,6 +56,7 @@ import {
|
||||
monthRange,
|
||||
weekRange
|
||||
} from './risk-review.compute';
|
||||
import { RiskChangeDialogComponent, RiskChangeDialogData } from './risk-change-dialog.component';
|
||||
|
||||
type PeriodMode = 'day' | 'week' | 'month' | 'range';
|
||||
type ViewMode = 'customer' | 'alert' | 'txn';
|
||||
@@ -109,6 +113,7 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
private clients = inject(ClientsService);
|
||||
private scope = inject(BranchScopeService);
|
||||
private router = inject(Router);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
readonly period = GL_PERIOD;
|
||||
readonly branches = GL_BRANCHES;
|
||||
@@ -181,21 +186,37 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
|
||||
// ── Loading ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The nightly job's verdict per customer, keyed on client id.
|
||||
*
|
||||
* Empty until the first load, and stays empty on a tenant where the job has not run - the report then falls back to
|
||||
* its own per-alert reckoning rather than showing nothing.
|
||||
*/
|
||||
private behaviourWindows: BehaviourWindowByClient = {};
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.aml
|
||||
.getAlerts()
|
||||
.pipe(catchError(() => of([] as LiveAmlAlert[])))
|
||||
.subscribe((live) => {
|
||||
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
|
||||
this.liveAlerts = rows;
|
||||
this.compose();
|
||||
if (!rows.length) {
|
||||
this.loading = false;
|
||||
return;
|
||||
// Both in one pass: the windows decide what each row recommends, so composing before they arrive would show a
|
||||
// recommendation the screen then silently replaced.
|
||||
forkJoin({
|
||||
alerts: this.aml.getAlerts().pipe(catchError(() => of([] as LiveAmlAlert[]))),
|
||||
windows: this.aml.getBehaviourWindows().pipe(catchError(() => of([] as any[])))
|
||||
}).subscribe(({ alerts: live, windows }) => {
|
||||
this.behaviourWindows = {};
|
||||
for (const w of windows || []) {
|
||||
if (w?.clientId != null) {
|
||||
this.behaviourWindows[w.clientId] = w;
|
||||
}
|
||||
this.enrichCurrentRisk(rows);
|
||||
});
|
||||
}
|
||||
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
|
||||
this.liveAlerts = rows;
|
||||
this.compose();
|
||||
if (!rows.length) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
this.enrichCurrentRisk(rows);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -336,9 +357,11 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY };
|
||||
|
||||
private get model(): RiskReviewModel {
|
||||
const key = JSON.stringify(this.filter) + `|${this.alerts.length}`;
|
||||
// The window count belongs in the key: the windows decide what each row recommends, and they arrive in the same
|
||||
// load as the alerts. Keying on the alerts alone would serve the memo computed before they landed.
|
||||
const key = JSON.stringify(this.filter) + `|${this.alerts.length}|${Object.keys(this.behaviourWindows).length}`;
|
||||
if (key !== this.memoKey) {
|
||||
this.memo = buildRiskReview(this.alerts, this.filter);
|
||||
this.memo = buildRiskReview(this.alerts, this.filter, this.behaviourWindows);
|
||||
this.memoKey = key;
|
||||
}
|
||||
return this.memo;
|
||||
@@ -364,10 +387,63 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
* a regulatory clock, an under-rated customer is the real compliance gap, an over-rated one is
|
||||
* only a correction, and matching ratings need nothing at all.
|
||||
*/
|
||||
/**
|
||||
* A report row for a customer the behaviour review named but no alert brought onto the report.
|
||||
*
|
||||
* <p>
|
||||
* The alert-derived fields are empty because there are none in the window, and that is the point being shown: the
|
||||
* case rests on the period, not on anything that fired. The figures the verdict was drawn from travel on
|
||||
* behaviourWindow, so the row can still explain itself.
|
||||
*/
|
||||
private rowFromWindow(w: any): RiskCustomerRow {
|
||||
const RANK: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
|
||||
const current = RANK[w.riskCategoryAtReview] ?? null;
|
||||
const recommended = RANK[w.suggestedCategory] ?? current ?? 'LOW';
|
||||
return {
|
||||
clientId: w.clientId,
|
||||
customerName: w.clientName || `Client ${w.clientId}`,
|
||||
accountNo: w.accountNo || '',
|
||||
branchCode: w.officeName || '',
|
||||
currentRisk: current,
|
||||
alerts: [],
|
||||
alertCount: 0,
|
||||
totalAmount: Number(w.creditTotal || 0) + Number(w.debitTotal || 0),
|
||||
largestAmount: Number(w.largestTxn || 0),
|
||||
proposedRisk: recommended,
|
||||
recommendedRisk: recommended,
|
||||
direction: w.suggestedDirection === 'UP' ? 'up' : w.suggestedDirection === 'DOWN' ? 'down' : 'same',
|
||||
directionReason: w.suggestionReason || '',
|
||||
ruleLabels: [],
|
||||
firstAlertDate: '',
|
||||
lastAlertDate: '',
|
||||
openCount: 0,
|
||||
escalated: false,
|
||||
needsUpgrade: w.suggestedDirection === 'UP',
|
||||
needsDowngrade: w.suggestedDirection === 'DOWN',
|
||||
behaviourWindow: w
|
||||
};
|
||||
}
|
||||
|
||||
get customerGroups(): RiskCustomerGroup[] {
|
||||
const rows = this.customerRows;
|
||||
const escalated = rows.filter((c) => c.escalated);
|
||||
const rest = rows.filter((c) => !c.escalated);
|
||||
// An escalated customer whose rating still has to move belongs with the work, not with the cases that are waiting
|
||||
// on someone else. An open STR holds a rating at High, and if the customer is not yet at High that is a change
|
||||
// somebody has to apply - filing the STR does not make it. Only a customer whose rating already matches sits under
|
||||
// the Principal Officer with nothing to do.
|
||||
const escalated = rows.filter((c) => c.escalated && c.direction === 'same');
|
||||
const rest = rows.filter((c) => !(c.escalated && c.direction === 'same'));
|
||||
|
||||
// Customers the nightly review says to move who raised no alert inside the filter window.
|
||||
//
|
||||
// The report is built from alerts, so a customer appears only once a rule has fired on them recently. That is the
|
||||
// wrong test for a downgrade: the whole case for lowering a rating is that nothing has fired for a year, so the
|
||||
// customer with the strongest case is precisely the one the alert feed has stopped mentioning. Without this they
|
||||
// would sit in the behaviour table with a verdict nobody ever sees.
|
||||
const onReport = new Set(rows.map((r) => r.clientId));
|
||||
const offReport: RiskCustomerRow[] = Object.values(this.behaviourWindows)
|
||||
.filter((w: any) => w?.clientId != null && !onReport.has(w.clientId))
|
||||
.filter((w: any) => w.suggestedDirection === 'UP' || w.suggestedDirection === 'DOWN')
|
||||
.map((w: any) => this.rowFromWindow(w));
|
||||
|
||||
const groups: RiskCustomerGroup[] = [
|
||||
{
|
||||
@@ -378,25 +454,42 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
rows: escalated
|
||||
},
|
||||
{
|
||||
key: 'up',
|
||||
title: 'Rating needs raising',
|
||||
hint: 'The rules point higher than the rating on file. These are under-rated customers — the real gap.',
|
||||
// Raising and lowering are one section, not two. The heading answers "is there anything to do about this
|
||||
// customer", and which way the rating moves is already on every row - as a verdict, an arrow and a reason.
|
||||
// Splitting them put the same answer in two places and left an officer reading two lists to find one day's
|
||||
// work. Raises sort first within the section, because an under-rated customer is the more urgent gap.
|
||||
key: 'change',
|
||||
title: 'Change needed',
|
||||
hint: 'Every condition for moving the rating is met. Apply the change; it goes to a checker for approval.',
|
||||
tone: 'up',
|
||||
rows: rest.filter((c) => c.direction === 'up')
|
||||
rows: [
|
||||
...rest.filter((c) => c.currentRisk && c.direction === 'up'),
|
||||
...offReport.filter((c) => c.direction === 'up'),
|
||||
...rest.filter((c) => c.currentRisk && c.direction === 'down'),
|
||||
...offReport.filter((c) => c.direction === 'down')
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'down',
|
||||
title: 'Rating can be lowered',
|
||||
hint: 'Activity no longer supports the rating on file.',
|
||||
tone: 'down',
|
||||
rows: rest.filter((c) => c.direction === 'down')
|
||||
// Its own section, and above "no change needed", because it is the opposite of that. An unrated customer
|
||||
// whose money is moving has no rating for the behaviour to be compared against, so nothing here can say
|
||||
// whether it is in keeping - the work is to rate them, and grouping them under "no change needed" buried the
|
||||
// one action the report was asking for.
|
||||
key: 'unrated',
|
||||
title: 'Rating needed',
|
||||
hint: 'These customers are transacting with no rating on file. Nothing can be judged against a rating that does not exist — rate them first.',
|
||||
tone: 'up',
|
||||
rows: rest.filter((c) => !c.currentRisk)
|
||||
},
|
||||
{
|
||||
// Everything an alert put on the report that the policy will not move. "No change needed" read as though
|
||||
// these customers were fine, which is not what it means: a High-rated customer with four open alerts is on
|
||||
// this list, and the reason is that the conditions for lowering the rating are not met - not that there is
|
||||
// nothing to look at. The reason on each row says which condition fell short.
|
||||
key: 'same',
|
||||
title: 'No change needed',
|
||||
hint: 'The rating on file already matches the behaviour. Listed for the record.',
|
||||
title: 'Rating holds — no change this period',
|
||||
hint: 'An alert brought these customers onto the report, but the conditions for moving the rating are not met. The reason on each row says what is missing.',
|
||||
tone: 'hold',
|
||||
rows: rest.filter((c) => c.direction === 'same')
|
||||
rows: rest.filter((c) => c.currentRisk && c.direction === 'same')
|
||||
}
|
||||
];
|
||||
|
||||
@@ -575,6 +668,67 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a case that is with the Principal Officer to the STR queue.
|
||||
*
|
||||
* The rating cannot be moved while an STR is open, so the useful thing to offer is the case
|
||||
* itself. Sending the reviewer to the customer's KYC record instead made them land on a screen
|
||||
* where the one action they might take is the one thing they must not do yet.
|
||||
*/
|
||||
openStrCase(c: RiskCustomerRow, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.router.navigate(['/fiu'], { queryParams: { tab: 'str' } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this row's rating can be moved from here.
|
||||
*
|
||||
* A case with the Principal Officer is deliberately excluded: the rating is held where it is
|
||||
* until the STR concludes, which is what the section heading on that group says. Offering the
|
||||
* button there would invite a change nobody is in a position to make yet.
|
||||
*/
|
||||
canChangeRisk(c: RiskCustomerRow): boolean {
|
||||
return !c.escalated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises a rating change for approval, rather than sending the reviewer to the KYC screen to make
|
||||
* it themselves.
|
||||
*
|
||||
* The button used to navigate away: the reviewer left the report, found the customer, edited the
|
||||
* record, and the change took effect on their say-so alone with no second pair of eyes and no
|
||||
* record of why. The rating governs re-KYC frequency and AML thresholds, so it goes through a
|
||||
* checker like every other decision of that weight.
|
||||
*/
|
||||
changeRisk(c: RiskCustomerRow, event: Event): void {
|
||||
event.stopPropagation();
|
||||
if (!this.canChangeRisk(c)) {
|
||||
return;
|
||||
}
|
||||
this.dialog
|
||||
.open(RiskChangeDialogComponent, {
|
||||
width: '520px',
|
||||
maxWidth: '95vw',
|
||||
disableClose: true,
|
||||
data: {
|
||||
clientId: c.clientId,
|
||||
customerName: c.customerName,
|
||||
currentCategory: riskRank(c.currentRisk) || null,
|
||||
recommendedCategory: riskRank(c.recommendedRisk) || null,
|
||||
recommendationReason: c.directionReason
|
||||
} as RiskChangeDialogData
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((sent) => {
|
||||
if (sent) {
|
||||
// The rating has not moved yet - only a request exists - so the figures on screen are
|
||||
// still correct. Reloaded anyway so a second click cannot raise a duplicate request the
|
||||
// server would refuse.
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What the reviewer is going to the KYC screen to do.
|
||||
*
|
||||
@@ -589,25 +743,41 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
* change nobody is authorised to make yet.
|
||||
*/
|
||||
directionText(c: RiskCustomerRow): string {
|
||||
if (c.escalated) {
|
||||
// Escalation is only the whole story when the rating already matches. An STR holds a customer at High, so one
|
||||
// filed against a Medium-rated customer leaves a rating that still has to be moved - saying "held" there reports
|
||||
// the case and hides the work, which is how this row came to sit under "Change needed" announcing that nothing
|
||||
// needed doing.
|
||||
if (c.escalated && c.direction === 'same') {
|
||||
return 'Held — with Principal Officer';
|
||||
}
|
||||
if (c.direction === 'up') {
|
||||
return `Raise to ${RISK_LABEL[c.recommendedRisk]}`;
|
||||
// Both ends of the move, not just the destination. "Lower to Medium" reads as a verdict on its own and leaves the
|
||||
// officer joining it to the rating column to see what is actually changing; "High → Medium" is the whole fact in
|
||||
// one glance, and on an exported sheet it still says what happened without the column beside it.
|
||||
if (c.direction === 'up' || c.direction === 'down') {
|
||||
const from = c.currentRisk ? RISK_LABEL[c.currentRisk] : 'Unrated';
|
||||
return `${from} → ${RISK_LABEL[c.recommendedRisk]}`;
|
||||
}
|
||||
if (c.direction === 'down') {
|
||||
return `Lower to ${RISK_LABEL[c.recommendedRisk]}`;
|
||||
}
|
||||
return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'No rating on record';
|
||||
// An unrated customer is told what to do, not what was concluded. "No rating on record" states the gap and stops
|
||||
// there, which on a worklist reads as though the row needs nothing - and it is in fact the row that needs the most.
|
||||
return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'Rate this customer';
|
||||
}
|
||||
|
||||
directionIcon(c: RiskCustomerRow): string {
|
||||
if (c.escalated) {
|
||||
// Same test as directionText: a padlock says the case is out of the officer's hands, which is untrue while the
|
||||
// rating still has to be moved.
|
||||
if (c.escalated && c.direction === 'same') {
|
||||
return 'lock';
|
||||
}
|
||||
// 'check' rather than an equals sign: faEquals is not in the shared library, and an
|
||||
// unregistered icon throws at render and takes the whole row down with it.
|
||||
return c.direction === 'up' ? 'arrow-up' : c.direction === 'down' ? 'arrow-down' : 'check';
|
||||
if (c.direction === 'up') {
|
||||
return 'arrow-up';
|
||||
}
|
||||
if (c.direction === 'down') {
|
||||
return 'arrow-down';
|
||||
}
|
||||
// A tick claims the row is settled. An unrated customer is not settled, so it gets the mark that asks a question
|
||||
// rather than the one that closes it. 'exclamation-circle' rather than plain 'exclamation': only the registered
|
||||
// names resolve, and an unregistered icon throws at render and takes the whole row down with it.
|
||||
return c.currentRisk ? 'check' : 'exclamation-circle';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
AmlRuleMeta,
|
||||
CTR_THRESHOLD,
|
||||
MATERIALITY,
|
||||
NON_MITIGABLE_RULES,
|
||||
isNonMitigableRule,
|
||||
REVIEW_STATUS_META,
|
||||
RISK_LABEL,
|
||||
RiskAlertRow,
|
||||
@@ -109,6 +109,8 @@ export interface RiskCustomerRow {
|
||||
needsUpgrade: boolean;
|
||||
/** True when the rating on record is higher than the activity warrants. */
|
||||
needsDowngrade: boolean;
|
||||
/** The review period the recommendation was drawn from, where the nightly job has computed one. */
|
||||
behaviourWindow?: BehaviourWindowByClient[number] | null;
|
||||
}
|
||||
|
||||
export interface RiskReviewSummary {
|
||||
@@ -158,7 +160,7 @@ function recommendRating(
|
||||
ruleCodes: string[]
|
||||
): { risk: RiskLevel; reason: string } {
|
||||
const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW';
|
||||
const grave = ruleCodes.find((c) => NON_MITIGABLE_RULES.has(c));
|
||||
const grave = ruleCodes.find((c) => isNonMitigableRule(c));
|
||||
const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal;
|
||||
const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
|
||||
|
||||
@@ -271,7 +273,36 @@ export function riskAlertViews(alerts: RiskAlertRow[], filter: RiskReviewFilter)
|
||||
* escalated, then rated below what the rules point at, then most alerts —
|
||||
* come to the top of the worklist.
|
||||
*/
|
||||
export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
|
||||
/**
|
||||
* The behaviour window the nightly job computed for one customer, where there is one.
|
||||
*
|
||||
* Keyed on client id. A customer with no window - one the job has not seen yet, or a tenant where the job has never
|
||||
* run - falls back to the per-alert reckoning below, so the report renders either way.
|
||||
*/
|
||||
export interface BehaviourWindowByClient {
|
||||
[clientId: number]: {
|
||||
clientId: number;
|
||||
clientName: string | null;
|
||||
accountNo: string | null;
|
||||
officeName: string | null;
|
||||
riskCategoryAtReview: number | null;
|
||||
txnCount: number | null;
|
||||
distinctMonths: number | null;
|
||||
windowFrom: any;
|
||||
windowTo: any;
|
||||
creditTotal: number | null;
|
||||
debitTotal: number | null;
|
||||
largestTxn: number | null;
|
||||
cashTotal: number | null;
|
||||
alertCount: number | null;
|
||||
strCaseCount: number | null;
|
||||
suggestedDirection: string | null;
|
||||
suggestedCategory: number | null;
|
||||
suggestionReason: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindowByClient = {}): RiskCustomerRow[] {
|
||||
const byClient = new Map<number, RiskAlertView[]>();
|
||||
for (const v of views) {
|
||||
const list = byClient.get(v.clientId);
|
||||
@@ -297,19 +328,53 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
|
||||
const largestAmount = Math.max(...alerts.map((a) => a.amount));
|
||||
const escalated = alerts.some((a) => a.status === 'ESCALATED_STR' || a.status === 'STR_FILED');
|
||||
|
||||
const recommendation = recommendRating(
|
||||
proposedRank,
|
||||
currentRisk,
|
||||
largestAmount,
|
||||
totalAmount,
|
||||
escalated,
|
||||
alerts.map((a) => a.ruleCode)
|
||||
);
|
||||
// The server's own reckoning, where the nightly job has produced one.
|
||||
//
|
||||
// It is the better answer, and not because it is newer: it is drawn from a review period rather than from the
|
||||
// alerts on this screen. A customer appears here only once a rule has fired, so judging them on those alerts
|
||||
// alone describes the exceptions and never the ordinary months around them - which is exactly what a rating is
|
||||
// supposed to reflect. The job also tests the things this screen cannot see at all: how many separate months the
|
||||
// activity falls in, how long the customer has held the rating, whether an STR inside the block window forbids
|
||||
// lowering it.
|
||||
//
|
||||
// Without a window - a tenant where the job has not run, or a customer it has not reached - the per-alert
|
||||
// reckoning below still stands, so the report never renders blank while the two are in step.
|
||||
// A window is only good for as long as the rating it was computed against. The job takes a snapshot each night
|
||||
// and records the rating the customer held at the time; once an officer moves that rating the snapshot describes
|
||||
// a customer who no longer exists, and the screen would go on asking for a change that has already been applied
|
||||
// and approved. Dropping it here falls back to the per-alert reckoning until tonight's job catches up.
|
||||
const raw = windows[clientId];
|
||||
const staleWindow =
|
||||
raw?.riskCategoryAtReview != null && currentRisk != null && RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk;
|
||||
const window = staleWindow ? undefined : raw;
|
||||
const recommendation = window?.suggestedDirection
|
||||
? {
|
||||
risk: RANK_TO_LEVEL[window.suggestedCategory ?? 0] ?? currentRisk ?? 'LOW',
|
||||
reason: window.suggestionReason ?? ''
|
||||
}
|
||||
: recommendRating(
|
||||
proposedRank,
|
||||
currentRisk,
|
||||
largestAmount,
|
||||
totalAmount,
|
||||
escalated,
|
||||
alerts.map((a) => a.ruleCode)
|
||||
);
|
||||
|
||||
// Where the customer carries no rating there is nothing to move, so an
|
||||
// unrated customer is never counted as an upgrade or a downgrade.
|
||||
const delta = currentRisk ? riskRank(recommendation.risk) - riskRank(currentRisk) : 0;
|
||||
const direction: RatingDirection = delta > 0 ? 'up' : delta < 0 ? 'down' : 'same';
|
||||
const direction: RatingDirection = window?.suggestedDirection
|
||||
? window.suggestedDirection === 'UP'
|
||||
? 'up'
|
||||
: window.suggestedDirection === 'DOWN'
|
||||
? 'down'
|
||||
: 'same'
|
||||
: delta > 0
|
||||
? 'up'
|
||||
: delta < 0
|
||||
? 'down'
|
||||
: 'same';
|
||||
|
||||
rows.push({
|
||||
clientId,
|
||||
@@ -332,7 +397,10 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
|
||||
openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length,
|
||||
escalated,
|
||||
needsUpgrade: direction === 'up',
|
||||
needsDowngrade: direction === 'down'
|
||||
needsDowngrade: direction === 'down',
|
||||
// Carried through so the screen can show the period a recommendation was drawn from. A rating an officer is
|
||||
// asked to approve has to show its working, and the alerts on this row are not it.
|
||||
behaviourWindow: window ?? null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -461,9 +529,13 @@ export interface RiskReviewModel {
|
||||
* the summary all run once per filter change rather than once per template
|
||||
* binding, which matters because every view reads from the same result.
|
||||
*/
|
||||
export function buildRiskReview(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskReviewModel {
|
||||
export function buildRiskReview(
|
||||
alerts: RiskAlertRow[],
|
||||
filter: RiskReviewFilter,
|
||||
windows: BehaviourWindowByClient = {}
|
||||
): RiskReviewModel {
|
||||
const base = riskAlertViews(alerts, filter);
|
||||
const narrowed = applyDirectionFilter(base, riskCustomerRows(base), filter.direction);
|
||||
const narrowed = applyDirectionFilter(base, riskCustomerRows(base, windows), filter.direction);
|
||||
|
||||
return {
|
||||
alerts: narrowed.views,
|
||||
|
||||
Reference in New Issue
Block a user