Files
cbsnexora/src/app/risk-review/risk-review.compute.ts
T

569 lines
20 KiB
TypeScript

/**
* 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,
isNonMitigableRule,
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;
/** The review period the recommendation was drawn from, where the nightly job has computed one. */
behaviourWindow?: BehaviourWindowByClient[number] | null;
}
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) => isNonMitigableRule(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.`
};
}
// 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 {
risk,
reason: currentRisk
? `${sizes}. Consistent with ${RISK_LABEL[risk]} — the rating on file stands.`
: `${sizes}. Activity points at ${RISK_LABEL[risk]}; no rating on record to compare against.`
};
}
/** 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.
*/
/**
* 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);
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');
// 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 = window?.suggestedDirection
? window.suggestedDirection === 'UP'
? 'up'
: window.suggestedDirection === 'DOWN'
? 'down'
: 'same'
: 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',
// 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
});
}
// 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,
windows: BehaviourWindowByClient = {}
): RiskReviewModel {
const base = riskAlertViews(alerts, filter);
const narrowed = applyDirectionFilter(base, riskCustomerRows(base, windows), 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')}` };
}