feat: KYC risk profiling, AML flagged transactions bulk review, Risk Review report & Board Resolution register
This commit is contained in:
@@ -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