();
/** Short enough to feel live to an officer watching the desk, long enough not to hammer the API. */
@@ -146,7 +157,15 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
/** The alert whose detail row is open. One at a time: this is a reading pane, not a comparison. */
expandedId: number | null = null;
-
+ /**
+ * Opens or closes the detail pane. Nothing is recorded by reading.
+ *
+ *
+ * Expanding a row used to mark the alert UNDER_REVIEW, on the reasoning that opening the pane is where the work
+ * starts. It is not: most expands are an officer glancing at what a transaction tripped, and a status that changes
+ * on a single click describes the click rather than any review. The queue filled with alerts nobody was working,
+ * and the one state the flag existed to make visible — this one is actually being worked — became meaningless.
+ */
toggleExpanded(alert: any): void {
this.expandedId = this.expandedId === alert.id ? null : alert.id;
// Ticks belong to the alert they were made on, not to whichever pane is open next.
@@ -164,7 +183,9 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
* Served by the API. The fallback rebuilds the list from the flat fields for an alert read before the API carried
* it, so an older row still expands to something rather than appearing to have matched nothing.
*/
- matchedRules(alert: any): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] {
+ matchedRules(
+ alert: any
+ ): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] {
if (Array.isArray(alert?.matchedRules) && alert.matchedRules.length) {
return alert.matchedRules;
}
@@ -358,13 +379,226 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
readonly statusFilters: { key: string; label: string }[] = [
{ key: '', label: 'All alerts' },
{ key: 'RAISED', label: 'Raised' },
- { key: 'UNDER_REVIEW', label: 'Under review' },
+ // No "Under review" chip: nothing sets that status now that reading a row no longer records anything, so the
+ // chip would sit at zero for good. The status itself is kept — alerts predating this change still carry it, and
+ // canReview still treats them as open — but it is not offered as a filter nobody can populate.
{ key: 'ESCALATED', label: 'Escalated' },
{ key: 'WHITELISTED', label: 'Whitelisted' }
];
activeStatus = '';
private statusCounts: Record = {};
+ /**
+ * The period being worked.
+ *
+ * A monitoring feed accumulates: last week's alerts sit under today's, and an officer asked "what did we decide
+ * today" has to read past them. The period filter is what turns the feed into a day's, a week's or a month's work
+ * — and it composes with the status chips, so Today + Escalated answers "what did we mark suspicious today".
+ */
+ readonly periodModes: { key: PeriodMode; label: string }[] = [
+ { key: 'all', label: 'All time' },
+ { key: 'day', label: 'Day wise' },
+ { key: 'week', label: 'Week wise' },
+ { key: 'month', label: 'Month wise' },
+ { key: 'year', label: 'Year wise' }
+ ];
+ periodMode: PeriodMode = 'all';
+
+ /** The chosen day / any day inside the chosen week / the chosen month / the chosen year. */
+ filterDay = '';
+ filterWeek = '';
+ filterMonth = '';
+ filterYear = '';
+
+ /**
+ * Narrows the table to the rules firing most often in the chosen period.
+ *
+ *
+ * A rule tripping repeatedly is a different question from one transaction tripping a rule: it is either a real
+ * pattern in the book, or a rule set too tight and filling the queue with noise. Either way the rules at the top of
+ * the count are the ones worth an officer's attention first, and the long tail below them can wait.
+ *
+ *
+ * Null shows every rule. The options are deliberately small — a "top 10" that lists 40 rules answers nothing.
+ */
+ readonly topRuleOptions: (number | null)[] = [
+ null,
+ 10,
+ 20,
+ 50
+ ];
+ topRules: number | null = null;
+
+ setTopRules(n: number | null): void {
+ this.topRules = n;
+ this.applyStatusFilter(true);
+ }
+
+ /**
+ * The rule codes to keep, most-triggered first, or null when every rule is in view.
+ *
+ *
+ * Counted over the period and status already chosen rather than over the whole feed, so "top 10 this month" means
+ * the ten busiest rules of that month. Ties are broken on the rule code so the same set comes back each time
+ * instead of shifting between polls.
+ */
+ private topRuleCodes(pool: any[]): Set | null {
+ if (this.topRules === null) {
+ return null;
+ }
+ const counts = new Map();
+ for (const a of pool) {
+ const code = a?.ruleCode;
+ if (code) {
+ counts.set(code, (counts.get(code) ?? 0) + 1);
+ }
+ }
+ if (counts.size <= this.topRules) {
+ return null;
+ }
+ const ranked = Array.from(counts.entries())
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+ .slice(0, this.topRules)
+ .map(([code]) => code);
+ return new Set(ranked);
+ }
+
+ /** How many distinct rules the current view holds, so the chip can say what it is narrowing. */
+ get distinctRuleCount(): number {
+ return new Set(this.alerts.data.map((a) => a.ruleCode).filter((c) => c)).size;
+ }
+
+ /**
+ * The rules in view with how often each fired, busiest first.
+ *
+ *
+ * The point of ranking rules is to see which ones are driving the queue, and a count per rule is that answer.
+ * Without it "Top 10" narrows the table and leaves the officer to work out what it kept.
+ */
+ get ruleFrequency(): { code: string; count: number }[] {
+ const counts = new Map();
+ for (const a of this.alerts.data) {
+ if (a?.ruleCode) {
+ counts.set(a.ruleCode, (counts.get(a.ruleCode) ?? 0) + 1);
+ }
+ }
+ return Array.from(counts.entries())
+ .map(
+ ([
+ code,
+ count
+ ]) => ({ code, count })
+ )
+ .sort((a, b) => b.count - a.count || a.code.localeCompare(b.code));
+ }
+
+ setPeriodMode(mode: PeriodMode): void {
+ this.periodMode = mode;
+ // Seeded with today the first time a mode is picked, so choosing "Month wise" shows this month rather than an
+ // empty field the officer has to fill before anything happens.
+ const now = new Date();
+ const iso = this.isoOf(now);
+ if (mode === 'day' && !this.filterDay) {
+ this.filterDay = iso;
+ }
+ if (mode === 'week' && !this.filterWeek) {
+ this.filterWeek = iso;
+ }
+ if (mode === 'month' && !this.filterMonth) {
+ this.filterMonth = iso.slice(0, 7);
+ }
+ if (mode === 'year' && !this.filterYear) {
+ this.filterYear = String(now.getFullYear());
+ }
+ this.applyStatusFilter(true);
+ }
+
+ onPeriodChange(): void {
+ this.applyStatusFilter(true);
+ }
+
+ /** The years the feed actually covers, newest first, so the picker offers nothing empty. */
+ get availableYears(): string[] {
+ const years = new Set();
+ for (const a of this.allAlerts) {
+ if (a.raisedOnDate) {
+ years.add(String(a.raisedOnDate.getFullYear()));
+ }
+ }
+ const list = Array.from(years).sort().reverse();
+ return list.length ? list : [String(new Date().getFullYear())];
+ }
+
+ /** Reads back the resolved period, so the officer can see what "week wise" actually resolved to. */
+ get periodLabel(): string {
+ const range = this.periodRange();
+ if (!range) {
+ return '';
+ }
+ return range.from === range.to ? range.from : `${range.from} to ${range.to}`;
+ }
+
+ private isoOf(d: Date): string {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+ }
+
+ /**
+ * The chosen period as an inclusive from/to, or null for all time.
+ *
+ * Week runs Monday to Sunday. Built from local date parts rather than by parsing to UTC: an alert raised late in
+ * the evening is on that evening's date for the officer looking at it, and a UTC round-trip would move it.
+ */
+ private periodRange(): { from: string; to: string } | null {
+ switch (this.periodMode) {
+ case 'day':
+ return this.filterDay ? { from: this.filterDay, to: this.filterDay } : null;
+ case 'week': {
+ if (!this.filterWeek) {
+ return null;
+ }
+ const [
+ y,
+ m,
+ d
+ ] = this.filterWeek.split('-').map(Number);
+ const picked = new Date(y, m - 1, d);
+ const backToMonday = picked.getDay() === 0 ? 6 : picked.getDay() - 1;
+ const monday = new Date(y, m - 1, d - backToMonday);
+ const sunday = new Date(y, m - 1, d - backToMonday + 6);
+ return { from: this.isoOf(monday), to: this.isoOf(sunday) };
+ }
+ case 'month': {
+ if (!this.filterMonth) {
+ return null;
+ }
+ const [
+ y,
+ m
+ ] = this.filterMonth.split('-').map(Number);
+ // Day 0 of the next month is the last day of this one, which handles February and leap years without a table.
+ return { from: `${this.filterMonth}-01`, to: this.isoOf(new Date(y, m, 0)) };
+ }
+ case 'year':
+ return this.filterYear ? { from: `${this.filterYear}-01-01`, to: `${this.filterYear}-12-31` } : null;
+ default:
+ return null;
+ }
+ }
+
+ /** Whether an alert was raised inside the chosen period. */
+ private inActivePeriod(alert: any): boolean {
+ const range = this.periodRange();
+ if (!range) {
+ return true;
+ }
+ const d: Date | null = alert?.raisedOnDate;
+ if (!d) {
+ return false;
+ }
+ const iso = this.isoOf(d);
+ return iso >= range.from && iso <= range.to;
+ }
+
/**
* The severity tile currently narrowing the table: 1 Low, 2 Medium, 3 High, 0 unclassified, or
* null for all. Independent of {@link activeStatus} — the two compose, so an officer can ask for
@@ -530,11 +764,16 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
* it — doing that on the first pass left pageSize at 0 and emptied the table.
*/
private applyStatusFilter(resetPage = false): void {
- const byStatus = this.activeStatus
- ? this.allAlerts.filter((a) => a.status === this.activeStatus)
- : this.allAlerts;
- this.alerts.data =
+ // The day narrows everything else: the status chips and the severity tiles are asking "of what is in view", and
+ // an officer who has picked a day expects their counts to describe that day rather than the whole feed.
+ const byDay = this.allAlerts.filter((a) => this.inActivePeriod(a));
+ const byStatus = this.activeStatus ? byDay.filter((a) => a.status === this.activeStatus) : byDay;
+ const bySeverity =
this.activeSeverity === null ? byStatus : byStatus.filter((a) => this.severityBand(a) === this.activeSeverity);
+ // Ranked last, over what the other filters left: "top 10 this month" is the ten busiest rules of that month, not
+ // the ten busiest overall then filtered down to a handful.
+ const keep = this.topRuleCodes(bySeverity);
+ this.alerts.data = keep ? bySeverity.filter((a) => keep.has(a.ruleCode)) : bySeverity;
if (!this.alerts.paginator && this.paginator) {
this.alerts.paginator = this.paginator;
}
@@ -573,15 +812,119 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
* add up to the total while one of them is narrowing the table, and the total reads as the way
* back to all of them.
*/
+ exporting = false;
+
+ /**
+ * Exports exactly what the table is showing.
+ *
+ *
+ * Built from `alerts.data` rather than from the full feed, so the period, status and severity an officer chose are
+ * the period, status and severity in the file. A workbook that quietly held more than the screen did would be
+ * worse than none: the figures in it would not reconcile with the report it came from, and an officer sending it
+ * on has no way to tell.
+ *
+ *
+ * The filters are written into the subtitle and the file name for the same reason — a sheet that says "10 alerts"
+ * with nothing about which month it covers cannot be checked later by anyone, including the person who made it.
+ */
+ async exportExcel(): Promise {
+ const rows = this.alerts.data;
+ if (!rows.length || this.exporting) {
+ return;
+ }
+ this.exporting = true;
+ try {
+ const scope = [
+ this.periodLabel ? `Period: ${this.periodLabel}` : 'Period: all time',
+ `Status: ${this.activeStatus ? this.statusLabel(this.activeStatus) : 'all'}`,
+ `Severity: ${this.activeSeverity === null ? 'all' : this.severityLabel(this.activeSeverity)}`,
+ `Rules: ${this.topRules === null ? 'all' : `top ${this.topRules} by frequency`}`
+ ].join(' · ');
+
+ await exportWorkbook(
+ [
+ {
+ sheetName: 'Flagged Transactions',
+ title: 'AML — Flagged Transactions',
+ subtitle: `${scope} · ${rows.length} alert${rows.length === 1 ? '' : 's'} · generated ${new Date().toLocaleString('en-IN')}`,
+ headers: [
+ 'Alert ID',
+ 'Raised On',
+ 'Customer',
+ 'Customer ID',
+ 'Transaction',
+ 'Txn ID',
+ 'Governing Rule',
+ 'Rule Description',
+ 'All Rules Matched',
+ 'Severity',
+ 'Amount',
+ 'Status',
+ 'Reviewed By',
+ 'Review Notes'
+ ],
+ rows: rows.map((a) => [
+ a.id ?? null,
+ a.raisedOnDate ? a.raisedOnDate.toLocaleString('en-IN') : '',
+ a.clientDisplayName || '',
+ a.clientId ?? null,
+ a.transactionType || '',
+ a.transactionId ?? null,
+ a.ruleCode || '',
+ a.ruleDescription || '',
+ // Every rule the transaction tripped, not only the one it reports under: the governing rule alone can
+ // hide a high-severity finding that a reader of the sheet would need.
+ this.matchedRules(a)
+ .map((r) => r.ruleCode)
+ .join(', '),
+ this.severityLabel(this.severityBand(a)),
+ Number(a.amount) || 0,
+ a.status || '',
+ a.reviewedByUsername || '',
+ a.reviewNotes || ''
+ ]),
+ widths: [
+ 9,
+ 20,
+ 24,
+ 12,
+ 12,
+ 10,
+ 30,
+ 38,
+ 44,
+ 11,
+ 15,
+ 14,
+ 14,
+ 50
+ ]
+ }
+ ],
+ `AML-Flagged-${this.periodLabel || 'all-time'}`
+ );
+ } finally {
+ this.exporting = false;
+ }
+ }
+
+ private statusLabel(key: string): string {
+ return this.statusFilters.find((s) => s.key === key)?.label ?? key;
+ }
+
get summaryTotal(): number {
- return this.activeStatus ? this.allAlerts.filter((a) => a.status === this.activeStatus).length : this.allAlerts.length;
+ const pool = this.allAlerts.filter((a) => this.inActivePeriod(a));
+ return this.activeStatus ? pool.filter((a) => a.status === this.activeStatus).length : pool.length;
}
private recomputeStatusCounts(): void {
- this.statusCounts = { '': this.allAlerts.length };
+ // Counted within the chosen day, so "Escalated 1" means one escalated that day rather than one in the whole feed.
+ // A chip whose figure describes a different set from the table under it is worse than no figure.
+ const pool = this.allAlerts.filter((a) => this.inActivePeriod(a));
+ this.statusCounts = { '': pool.length };
for (const s of this.statusFilters) {
if (s.key) {
- this.statusCounts[s.key] = this.allAlerts.filter((a) => a.status === s.key).length;
+ this.statusCounts[s.key] = pool.filter((a) => a.status === s.key).length;
}
}
}
@@ -633,7 +976,26 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
* opening a second STR case — this gate is what prevents it.
*/
canReview(alert: any): boolean {
- return alert?.status === 'RAISED' || alert?.status === 'UNDER_REVIEW';
+ if (alert?.status !== 'RAISED' && alert?.status !== 'UNDER_REVIEW') {
+ return false;
+ }
+ // An alert another officer has open is theirs until they decide it or hand it back. The endpoint refuses it
+ // anyway; hiding the buttons is what stops an officer working a case they will not be allowed to close.
+ return !this.heldByAnother(alert);
+ }
+
+ /** True when this alert is under review by somebody other than the signed-in officer. */
+ heldByAnother(alert: any): boolean {
+ return (
+ alert?.status === 'UNDER_REVIEW' &&
+ !!alert?.reviewedByUsername &&
+ alert.reviewedByUsername !== this.currentUsername
+ );
+ }
+
+ /** Who has the alert, for the message shown in place of the action buttons. */
+ heldBy(alert: any): string {
+ return alert?.reviewedByUsername || 'another officer';
}
review(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts
index 2d6825d64..beddd4527 100644
--- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts
+++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts
@@ -196,7 +196,12 @@ export class AmlReviewAlertDialogComponent {
// Deciding named rules within the alert rather than the whole alert, when the caller passed some. The evidence
// requirements are the same either way, which is why this dialog serves both.
const request = this.data.ruleCodes?.length
- ? this.amlService.reviewAlertRules(this.data.alert.id, this.data.ruleCodes, this.data.disposition, this.composedNotes)
+ ? this.amlService.reviewAlertRules(
+ this.data.alert.id,
+ this.data.ruleCodes,
+ this.data.disposition,
+ this.composedNotes
+ )
: this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes);
request.subscribe({
next: () => {
diff --git a/src/app/risk-review/risk-change-approvals.component.ts b/src/app/risk-review/risk-change-approvals.component.ts
index 51a3e1798..677928e01 100644
--- a/src/app/risk-review/risk-change-approvals.component.ts
+++ b/src/app/risk-review/risk-change-approvals.component.ts
@@ -92,7 +92,9 @@ const CATEGORY_LABEL: Record = { 1: 'Low', 2: 'Medium', 3: 'High
| {{ r.reason }} |
- {{ r.status }}
+ {{
+ r.status
+ }}
|
{{ r.makerUsername }} |
{{ r.createdDate }} |
@@ -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);
+ 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 {
diff --git a/src/app/risk-review/risk-review-report.component.ts b/src/app/risk-review/risk-review-report.component.ts
index 3b4fd2fb4..ee9d5f2b9 100644
--- a/src/app/risk-review/risk-review-report.component.ts
+++ b/src/app/risk-review/risk-review-report.component.ts
@@ -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')
}
diff --git a/src/app/risk-review/risk-review.compute.ts b/src/app/risk-review/risk-review.compute.ts
index 558b80ab5..215fcac21 100644
--- a/src/app/risk-review/risk-review.compute.ts
+++ b/src/app/risk-review/risk-review.compute.ts
@@ -26,6 +26,17 @@ export type RatingDirection = 'up' | 'down' | 'same';
const RANK_TO_LEVEL: Record = { 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
diff --git a/src/environments/.env.ts b/src/environments/.env.ts
index 4509e26ef..6f0ee1ce1 100644
--- a/src/environments/.env.ts
+++ b/src/environments/.env.ts
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
- 'version': '260916',
- 'hash': 'c01615ee'
+ 'version': '260918',
+ 'hash': '0cef1b5a'
},
'allow_switching_backend_instance': true
};