risk review report with on which aml rule
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* 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 {
|
||||
AmlRuleMeta,
|
||||
CTR_THRESHOLD,
|
||||
MATERIALITY,
|
||||
NON_MITIGABLE_RULES,
|
||||
REVIEW_STATUS_META,
|
||||
RISK_LABEL,
|
||||
RiskAlertRow,
|
||||
RiskLevel,
|
||||
RiskTxn,
|
||||
ReviewStatus,
|
||||
amlRuleMeta,
|
||||
riskRank
|
||||
} from './risk-review-data';
|
||||
|
||||
/** Which way the review says the rating should move. */
|
||||
export type RatingDirection = 'up' | 'down' | 'same';
|
||||
|
||||
const RANK_TO_LEVEL: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
|
||||
|
||||
export interface DateRange {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface RiskReviewFilter {
|
||||
range: DateRange;
|
||||
/** '' means every branch. */
|
||||
branchCode: string;
|
||||
/** '' means every rating. */
|
||||
currentRisk: RiskLevel | '';
|
||||
/** '' means every rule. */
|
||||
ruleCode: string;
|
||||
/** '' means every review status. */
|
||||
status: ReviewStatus | '';
|
||||
/** '' means every customer, whichever way their rating should move. */
|
||||
direction: RatingDirection | '';
|
||||
/** Free text over customer name, customer ID, account number and alert ID. */
|
||||
search: string;
|
||||
/** Only cases a reviewer still has to act on. */
|
||||
openOnly: boolean;
|
||||
}
|
||||
|
||||
/** An alert joined to its rule catalogue entry, ready to render. */
|
||||
export interface RiskAlertView extends RiskAlertRow {
|
||||
rule: AmlRuleMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* One transaction carrying the alert and rule it belongs to.
|
||||
*
|
||||
* This is the row that answers "which AML rule did this transaction trip" —
|
||||
* the alert is flattened onto each of its entries so the same transaction can
|
||||
* be read on its own, without expanding a customer first.
|
||||
*/
|
||||
export interface RiskTxnView extends RiskTxn {
|
||||
alertId: string;
|
||||
alertDate: string;
|
||||
clientId: number;
|
||||
customerName: string;
|
||||
accountNo: string;
|
||||
branchCode: string;
|
||||
currentRisk: RiskLevel | null;
|
||||
status: ReviewStatus;
|
||||
remarks: string;
|
||||
rule: AmlRuleMeta;
|
||||
/** Position of this entry within its alert, e.g. "2 of 7" for a series. */
|
||||
legIndex: number;
|
||||
legCount: number;
|
||||
}
|
||||
|
||||
/** All of one customer's alerts in the period, rolled up. */
|
||||
export interface RiskCustomerRow {
|
||||
clientId: number;
|
||||
customerName: string;
|
||||
accountNo: string;
|
||||
branchCode: string;
|
||||
currentRisk: RiskLevel | null;
|
||||
alerts: RiskAlertView[];
|
||||
alertCount: number;
|
||||
/** Sum of the amounts behind the alerts — the exposure under review. */
|
||||
totalAmount: number;
|
||||
largestAmount: number;
|
||||
/** Highest risk any of the triggered rules points at, before materiality. */
|
||||
proposedRisk: RiskLevel;
|
||||
/** What the review concludes the rating should be, after materiality. */
|
||||
recommendedRisk: RiskLevel;
|
||||
/** Whether that is above, below or the same as the rating on record. */
|
||||
direction: RatingDirection;
|
||||
/** One line explaining the recommendation, shown under the verdict. */
|
||||
directionReason: string;
|
||||
/** Distinct rule labels, for the "AML Rule Triggered" cell. */
|
||||
ruleLabels: string[];
|
||||
/** Earliest and latest alert in the period. */
|
||||
firstAlertDate: string;
|
||||
lastAlertDate: string;
|
||||
openCount: number;
|
||||
/** True when any alert on the customer has gone to the Principal Officer. */
|
||||
escalated: boolean;
|
||||
/** True when the rules point above the rating currently on the KYC record. */
|
||||
needsUpgrade: boolean;
|
||||
/** True when the rating on record is higher than the activity warrants. */
|
||||
needsDowngrade: boolean;
|
||||
}
|
||||
|
||||
export interface RiskReviewSummary {
|
||||
customers: number;
|
||||
alerts: number;
|
||||
/** Individual entries behind those alerts. */
|
||||
txns: number;
|
||||
/** Cash entries at or above the CTR limit on their own. */
|
||||
ctrReportable: number;
|
||||
openAlerts: number;
|
||||
escalatedCustomers: number;
|
||||
upgradeCandidates: number;
|
||||
downgradeCandidates: number;
|
||||
totalAmount: number;
|
||||
byRisk: Record<'LOW' | 'MEDIUM' | 'HIGH' | 'UNRATED', number>;
|
||||
/** Rule mix across the filtered set, most-triggered first. */
|
||||
byRule: { code: string; label: string; count: number }[];
|
||||
byStatus: { status: ReviewStatus; label: string; count: number }[];
|
||||
}
|
||||
|
||||
function inRange(date: string, range: DateRange): boolean {
|
||||
return date >= range.from && date <= range.to;
|
||||
}
|
||||
|
||||
function inr(n: number): string {
|
||||
return `₹${Math.round(n).toLocaleString('en-IN')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out what the rating ought to be once the size of the activity is taken
|
||||
* into account, and why.
|
||||
*
|
||||
* The rules give an upper bound — what the behaviour could mean at worst. The
|
||||
* amounts then pull that bound down: a customer sitting at High who has only put
|
||||
* a handful of small transfers through has not behaved like a High-risk customer
|
||||
* this period, and the review should say so. Two things stop the pull-down: a
|
||||
* case already referred for an STR, and rules whose seriousness has nothing to
|
||||
* do with value — a sanctions match or a structuring pattern stays grave at any
|
||||
* amount.
|
||||
*/
|
||||
function recommendRating(
|
||||
impliedRank: number,
|
||||
currentRisk: RiskLevel | null,
|
||||
largestAmount: number,
|
||||
totalAmount: number,
|
||||
escalated: boolean,
|
||||
ruleCodes: string[]
|
||||
): { risk: RiskLevel; reason: string } {
|
||||
const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW';
|
||||
const grave = ruleCodes.find((c) => NON_MITIGABLE_RULES.has(c));
|
||||
const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal;
|
||||
const small = 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';
|
||||
if (escalated) {
|
||||
risk = 'HIGH';
|
||||
basis = 'escalated';
|
||||
} else if (grave) {
|
||||
risk = implied;
|
||||
basis = 'grave';
|
||||
} else if (material) {
|
||||
risk = implied;
|
||||
basis = 'material';
|
||||
} else {
|
||||
risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW';
|
||||
basis = small ? 'small' : 'moderate';
|
||||
}
|
||||
|
||||
// The wording is worked out from the conclusion, not the branch, so a customer
|
||||
// being brought down is never told the activity supports where they are.
|
||||
const sizes = `largest entry ${inr(largestAmount)}, ${inr(totalAmount)} in total`;
|
||||
const rated = currentRisk ? `Rated ${RISK_LABEL[currentRisk]} on record` : 'No rating on record';
|
||||
const delta = currentRisk ? riskRank(risk) - riskRank(currentRisk) : 0;
|
||||
|
||||
if (basis === 'escalated') {
|
||||
return { risk, reason: 'Case referred to the Principal Officer — rating held at High pending the outcome.' };
|
||||
}
|
||||
|
||||
if (delta < 0) {
|
||||
if (basis === 'small') {
|
||||
return { risk, reason: `Only small-value activity — ${sizes}. ${rated}; not supported by this period.` };
|
||||
}
|
||||
if (basis === 'moderate') {
|
||||
return { risk, reason: `Moderate activity — ${sizes}. ${rated}; one step down is proportionate.` };
|
||||
}
|
||||
// Material or grave, yet still below where the customer sits: the size of
|
||||
// the activity is not the reason, the rules that fired are.
|
||||
return {
|
||||
risk,
|
||||
reason:
|
||||
`Activity of ${inr(totalAmount)} is material, but the rules triggered point only at ${RISK_LABEL[risk]}. ` +
|
||||
`${rated}.`
|
||||
};
|
||||
}
|
||||
|
||||
if (delta > 0) {
|
||||
return { risk, reason: `Triggered rules point at ${RISK_LABEL[risk]}, above the rating on record.` };
|
||||
}
|
||||
|
||||
if (basis === 'grave') {
|
||||
return {
|
||||
risk,
|
||||
reason: `${amlRuleMeta(grave as string).label} carries its own weight — the rating does not come down on amount alone.`
|
||||
};
|
||||
}
|
||||
|
||||
return { risk, reason: '' };
|
||||
}
|
||||
|
||||
/** Applies every filter and joins each alert to its rule. */
|
||||
export function riskAlertViews(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskAlertView[] {
|
||||
const needle = filter.search.trim().toLowerCase();
|
||||
|
||||
return alerts
|
||||
.filter((a) => {
|
||||
if (!inRange(a.alertDate, filter.range)) {
|
||||
return false;
|
||||
}
|
||||
// A live alert carries no branch; hiding it under a branch filter would
|
||||
// silently drop real compliance data, so it stays visible.
|
||||
if (filter.branchCode && a.branchCode && a.branchCode !== filter.branchCode) {
|
||||
return false;
|
||||
}
|
||||
if (filter.currentRisk && a.currentRisk !== filter.currentRisk) {
|
||||
return false;
|
||||
}
|
||||
if (filter.ruleCode && a.ruleCode !== filter.ruleCode) {
|
||||
return false;
|
||||
}
|
||||
if (filter.status && a.status !== filter.status) {
|
||||
return false;
|
||||
}
|
||||
if (filter.openOnly && !REVIEW_STATUS_META[a.status].open) {
|
||||
return false;
|
||||
}
|
||||
if (needle) {
|
||||
const hay = `${a.customerName} ${a.clientId} ${a.accountNo} ${a.alertId} ${a.ruleCode}`.toLowerCase();
|
||||
if (!hay.includes(needle)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((a) => ({ ...a, rule: amlRuleMeta(a.ruleCode, a.ruleDescription) }))
|
||||
.sort((a, b) => b.alertDate.localeCompare(a.alertDate) || a.customerName.localeCompare(b.customerName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolls the alerts up per customer. Sorted so the cases needing attention —
|
||||
* 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[] {
|
||||
const byClient = new Map<number, RiskAlertView[]>();
|
||||
for (const v of views) {
|
||||
const list = byClient.get(v.clientId);
|
||||
if (list) {
|
||||
list.push(v);
|
||||
} else {
|
||||
byClient.set(v.clientId, [v]);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: RiskCustomerRow[] = [];
|
||||
for (const [
|
||||
clientId,
|
||||
alerts
|
||||
] of byClient) {
|
||||
const dates = alerts.map((a) => a.alertDate).sort();
|
||||
const proposedRank = Math.max(...alerts.map((a) => riskRank(a.rule.impliedRisk)));
|
||||
const proposedRisk: RiskLevel = RANK_TO_LEVEL[proposedRank] ?? 'LOW';
|
||||
const head = alerts[0];
|
||||
|
||||
const currentRisk = alerts.find((a) => a.currentRisk)?.currentRisk ?? null;
|
||||
const totalAmount = alerts.reduce((s, a) => s + a.amount, 0);
|
||||
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)
|
||||
);
|
||||
|
||||
// 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';
|
||||
|
||||
rows.push({
|
||||
clientId,
|
||||
customerName: head.customerName,
|
||||
// Take the first non-blank — live alerts leave these empty.
|
||||
accountNo: alerts.find((a) => a.accountNo)?.accountNo || '',
|
||||
branchCode: alerts.find((a) => a.branchCode)?.branchCode || '',
|
||||
currentRisk,
|
||||
alerts,
|
||||
alertCount: alerts.length,
|
||||
totalAmount,
|
||||
largestAmount,
|
||||
proposedRisk,
|
||||
recommendedRisk: recommendation.risk,
|
||||
direction,
|
||||
directionReason: recommendation.reason,
|
||||
ruleLabels: [...new Set(alerts.map((a) => a.rule.label))],
|
||||
firstAlertDate: dates[0],
|
||||
lastAlertDate: dates[dates.length - 1],
|
||||
openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length,
|
||||
escalated,
|
||||
needsUpgrade: direction === 'up',
|
||||
needsDowngrade: direction === 'down'
|
||||
});
|
||||
}
|
||||
|
||||
// Escalations first, then ratings that need moving up, then down, then volume.
|
||||
return rows.sort(
|
||||
(a, b) =>
|
||||
Number(b.escalated) - Number(a.escalated) ||
|
||||
Number(b.needsUpgrade) - Number(a.needsUpgrade) ||
|
||||
Number(b.needsDowngrade) - Number(a.needsDowngrade) ||
|
||||
b.alertCount - a.alertCount ||
|
||||
b.totalAmount - a.totalAmount
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restricts the alerts to customers whose rating should move a given way.
|
||||
*
|
||||
* Direction is a property of the customer, not of a single alert, so it can only
|
||||
* be applied once the rollup has run — the alert and transaction views are then
|
||||
* narrowed to the same customers so all three views agree.
|
||||
*/
|
||||
export function applyDirectionFilter(
|
||||
views: RiskAlertView[],
|
||||
customers: RiskCustomerRow[],
|
||||
direction: RatingDirection | ''
|
||||
): { views: RiskAlertView[]; customers: RiskCustomerRow[] } {
|
||||
if (!direction) {
|
||||
return { views, customers };
|
||||
}
|
||||
const keep = customers.filter((c) => c.direction === direction);
|
||||
const ids = new Set(keep.map((c) => c.clientId));
|
||||
return { views: views.filter((v) => ids.has(v.clientId)), customers: keep };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens every alert onto its transactions, newest entry first, so each row
|
||||
* names the rule that transaction tripped.
|
||||
*/
|
||||
export function riskTxnRows(views: RiskAlertView[]): RiskTxnView[] {
|
||||
const rows: RiskTxnView[] = [];
|
||||
|
||||
for (const v of views) {
|
||||
v.txns.forEach((t, i) => {
|
||||
rows.push({
|
||||
...t,
|
||||
alertId: v.alertId,
|
||||
alertDate: v.alertDate,
|
||||
clientId: v.clientId,
|
||||
customerName: v.customerName,
|
||||
accountNo: v.accountNo,
|
||||
branchCode: v.branchCode,
|
||||
currentRisk: v.currentRisk,
|
||||
status: v.status,
|
||||
remarks: v.remarks,
|
||||
rule: v.rule,
|
||||
legIndex: i + 1,
|
||||
legCount: v.txns.length
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return rows.sort((a, b) => b.date.localeCompare(a.date) || a.txnId.localeCompare(b.txnId));
|
||||
}
|
||||
|
||||
export function riskReviewSummary(views: RiskAlertView[], customers: RiskCustomerRow[]): RiskReviewSummary {
|
||||
const byRisk: RiskReviewSummary['byRisk'] = { LOW: 0, MEDIUM: 0, HIGH: 0, UNRATED: 0 };
|
||||
for (const c of customers) {
|
||||
byRisk[c.currentRisk ?? 'UNRATED']++;
|
||||
}
|
||||
|
||||
const ruleCounts = new Map<string, { label: string; count: number }>();
|
||||
for (const v of views) {
|
||||
const entry = ruleCounts.get(v.ruleCode);
|
||||
if (entry) {
|
||||
entry.count++;
|
||||
} else {
|
||||
ruleCounts.set(v.ruleCode, { label: v.rule.label, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const statusCounts = new Map<ReviewStatus, number>();
|
||||
for (const v of views) {
|
||||
statusCounts.set(v.status, (statusCounts.get(v.status) || 0) + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
customers: customers.length,
|
||||
alerts: views.length,
|
||||
txns: views.reduce((s, v) => s + v.txns.length, 0),
|
||||
ctrReportable: views.reduce((s, v) => s + v.txns.filter((t) => t.ctrReportable).length, 0),
|
||||
openAlerts: views.filter((v) => REVIEW_STATUS_META[v.status].open).length,
|
||||
escalatedCustomers: customers.filter((c) => c.escalated).length,
|
||||
upgradeCandidates: customers.filter((c) => c.needsUpgrade).length,
|
||||
downgradeCandidates: customers.filter((c) => c.needsDowngrade).length,
|
||||
totalAmount: views.reduce((s, v) => s + v.amount, 0),
|
||||
byRisk,
|
||||
byRule: [...ruleCounts.entries()]
|
||||
.map(
|
||||
([
|
||||
code,
|
||||
v
|
||||
]) => ({ code, label: v.label, count: v.count })
|
||||
)
|
||||
.sort((a, b) => b.count - a.count),
|
||||
byStatus: [...statusCounts.entries()]
|
||||
.map(
|
||||
([
|
||||
status,
|
||||
count
|
||||
]) => ({ status, label: REVIEW_STATUS_META[status].label, count })
|
||||
)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
};
|
||||
}
|
||||
|
||||
/** Everything the screen renders, computed in one pass. */
|
||||
export interface RiskReviewModel {
|
||||
alerts: RiskAlertView[];
|
||||
customers: RiskCustomerRow[];
|
||||
txns: RiskTxnView[];
|
||||
summary: RiskReviewSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single entry point for the screen. Filtering, rollup, direction narrowing and
|
||||
* 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 {
|
||||
const base = riskAlertViews(alerts, filter);
|
||||
const narrowed = applyDirectionFilter(base, riskCustomerRows(base), filter.direction);
|
||||
|
||||
return {
|
||||
alerts: narrowed.views,
|
||||
customers: narrowed.customers,
|
||||
txns: riskTxnRows(narrowed.views),
|
||||
summary: riskReviewSummary(narrowed.views, narrowed.customers)
|
||||
};
|
||||
}
|
||||
|
||||
// ── Period helpers (shared with the other period-filtered reports) ───────────
|
||||
|
||||
/** Monday to Saturday containing the given date. */
|
||||
export function weekRange(anyDateInWeek: string): DateRange {
|
||||
const d = new Date(`${anyDateInWeek}T00:00:00Z`);
|
||||
const dow = d.getUTCDay();
|
||||
const backToMonday = dow === 0 ? 6 : dow - 1;
|
||||
const monday = new Date(d.getTime() - backToMonday * 86400000);
|
||||
const saturday = new Date(monday.getTime() + 5 * 86400000);
|
||||
return { from: monday.toISOString().slice(0, 10), to: saturday.toISOString().slice(0, 10) };
|
||||
}
|
||||
|
||||
/** First to last day of the given yyyy-MM. */
|
||||
export function monthRange(month: string): DateRange {
|
||||
const [
|
||||
y,
|
||||
m
|
||||
] = month.split('-').map(Number);
|
||||
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||||
return { from: `${month}-01`, to: `${month}-${String(last).padStart(2, '0')}` };
|
||||
}
|
||||
Reference in New Issue
Block a user