/** * 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 = { 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(); 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(); 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(); 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')}` }; }