feat(aml,risk-review,accounting): update financial activity mapping options, AML flagged transactions filters, and risk review report
This commit is contained in:
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user