feat: cash-only registers, day book sections and DD amendments
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-09-03 18:26:22 +05:30
parent 6442562df0
commit fbce73fd17
111 changed files with 9076 additions and 3507 deletions
@@ -9,6 +9,7 @@
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
@@ -37,8 +38,7 @@ import {
RiskLevel,
channelLabel,
mapLiveAlert,
riskFromCategory,
sampleRiskAlerts
riskFromCategory
} from './risk-review-data';
import {
DateRange,
@@ -57,6 +57,15 @@ import {
type PeriodMode = 'day' | 'week' | 'month' | 'range';
type ViewMode = 'customer' | 'alert' | 'txn';
/** One block of the customer view: the customers that share a single verdict. */
interface RiskCustomerGroup {
key: string;
title: string;
hint: string;
tone: 'str' | 'up' | 'down' | 'hold';
rows: RiskCustomerRow[];
}
/** Starting value for the memo, used before the first filter pass runs. */
const EMPTY_SUMMARY: RiskReviewSummary = {
customers: 0,
@@ -82,9 +91,8 @@ const EMPTY_SUMMARY: RiskReviewSummary = {
* rating, move it, refresh KYC, or escalate to the Principal Officer for an
* STR — which is the vocabulary in `REVIEW_STATUS_META`.
*
* Rows come from the live rule engine. If the engine has raised nothing (or is
* unreachable) the report falls back to a deterministic sample set and says so
* on screen and in the workbook, so sample rows are never taken for live data.
* Rows come from the live rule engine only. If the engine has raised nothing
* (or is unreachable) the report shows as empty rather than padding itself.
*/
@Component({
selector: 'mifosx-risk-review-report',
@@ -100,6 +108,7 @@ export class RiskReviewReportComponent implements OnInit {
private aml = inject(AmlService);
private clients = inject(ClientsService);
private scope = inject(BranchScopeService);
private router = inject(Router);
readonly period = GL_PERIOD;
readonly branches = GL_BRANCHES;
@@ -122,22 +131,13 @@ export class RiskReviewReportComponent implements OnInit {
private liveAlerts: RiskAlertRow[] = [];
loading = true;
/**
* Live alerts and the demonstration set, in one worklist.
*
* Every row keeps its `live` flag and sample rows are tagged in the table, so
* a reviewer can still tell them apart at a glance.
*/
/** The worklist — live alerts from the rule engine. */
private alerts: RiskAlertRow[] = [];
get hasLive(): boolean {
return this.liveAlerts.length > 0;
}
get sampleCount(): number {
return this.alerts.filter((a) => !a.live).length;
}
// ── Filter state ───────────────────────────────────────────────────────────
/**
@@ -198,12 +198,16 @@ export class RiskReviewReportComponent implements OnInit {
});
}
/** Live alerts first, then the demonstration set behind them. */
/**
* Only what the AML engine actually raised.
*
* The demonstration set that used to be appended here is gone: a compliance report that mixes
* invented customers into live alerts is worse than an empty one, because every count, total and
* rule tally on the screen is then a number nobody can act on. An empty period now reads as an
* empty period.
*/
private compose(): void {
this.alerts = [
...this.liveAlerts,
...sampleRiskAlerts()
];
this.alerts = [...this.liveAlerts];
this.memoKey = '';
}
@@ -334,6 +338,64 @@ export class RiskReviewReportComponent implements OnInit {
return this.model.customers;
}
/**
* The customers split by what the reviewer actually has to do about them.
*
* The report's whole question is "does the rating on file still hold?", and there are only four
* answers. Grouping by the answer puts that question in the section heading, where it is read
* once, instead of repeating it on every row across a Move To and a Position column that said the
* same thing in different words.
*
* Ordered by what a reviewer must deal with first: an STR sits with the Principal Officer against
* a regulatory clock, an under-rated customer is the real compliance gap, an over-rated one is
* only a correction, and matching ratings need nothing at all.
*/
get customerGroups(): RiskCustomerGroup[] {
const rows = this.customerRows;
const escalated = rows.filter((c) => c.escalated);
const rest = rows.filter((c) => !c.escalated);
const groups: RiskCustomerGroup[] = [
{
key: 'escalated',
title: 'With the Principal Officer',
hint: 'An STR has been filed. The rating is held where it is until the outcome — nothing to apply here.',
tone: 'str',
rows: escalated
},
{
key: 'up',
title: 'Rating needs raising',
hint: 'The rules point higher than the rating on file. These are under-rated customers — the real gap.',
tone: 'up',
rows: rest.filter((c) => c.direction === 'up')
},
{
key: 'down',
title: 'Rating can be lowered',
hint: 'Activity no longer supports the rating on file.',
tone: 'down',
rows: rest.filter((c) => c.direction === 'down')
},
{
key: 'same',
title: 'No change needed',
hint: 'The rating on file already matches the behaviour. Listed for the record.',
tone: 'hold',
rows: rest.filter((c) => c.direction === 'same')
}
];
return groups.filter((g) => g.rows.length > 0);
}
/** Collapsed sections, by group key. 'No change needed' starts closed — nothing to act on. */
collapsedGroups: Record<string, boolean> = { same: true };
toggleGroup(key: string): void {
this.collapsedGroups[key] = !this.collapsedGroups[key];
}
get txnRows(): RiskTxnView[] {
return this.model.txns;
}
@@ -393,6 +455,74 @@ export class RiskReviewReportComponent implements OnInit {
return 'rr-verdict';
}
/** Colours the target rating by which way it moves. */
moveClass(c: RiskCustomerRow): string {
if (c.direction === 'up') {
return 'rr-move rr-move--up';
}
if (c.direction === 'down') {
return 'rr-move rr-move--down';
}
return 'rr-move rr-move--hold';
}
/**
* The short "from → to" line under the target rating.
*
* The reason column already carries the full argument; this only has to say what is
* happening to the rating, so a reviewer scanning the column never has to read a
* paragraph to find out whether a customer is moving.
*/
moveHint(c: RiskCustomerRow): string {
const from = this.riskText(c.currentRisk);
const to = this.riskText(c.recommendedRisk);
if (c.escalated) {
return `${from} → hold at ${to} till STR outcome`;
}
if (c.direction === 'up') {
return `${from} → ${to} as per AML rules`;
}
if (c.direction === 'down') {
return `${from} → ${to}, activity does not support ${from}`;
}
// Nothing to move: say why it is staying rather than leaving the cell mute.
return c.currentRisk ? `Behaviour matches ${from} — no change` : 'No rating on record yet';
}
/**
* Opens the customer's KYC tab so the reviewer can act on the verdict.
*
* The report only ever recommends — nothing here writes the rating. Moving a customer
* is a KYC amendment and stays on the KYC screen, where the override checkbox, the
* remarks field and the review-date recalculation all live together.
*/
openKyc(c: RiskCustomerRow, event: Event): void {
// The row itself expands on click; the button must not do both.
event.stopPropagation();
this.router.navigate([
'/clients',
c.clientId,
'kyc'
]);
}
/**
* What the reviewer is going to the KYC screen to do.
*
* An escalated case is worded as a review rather than a move: the rating is held
* pending the Principal Officer's conclusion, so there is nothing to apply yet.
*/
kycActionText(c: RiskCustomerRow): string {
if (c.escalated) {
return 'Open KYC';
}
if (c.direction === 'up' || c.direction === 'down') {
return `Set ${RISK_LABEL[c.recommendedRisk]} in KYC`;
}
return 'Open KYC';
}
/** Transaction detail as the reviewer would read it in one line. */
txnDetail(a: RiskAlertView): string {
const amount = `₹${a.amount.toLocaleString('en-IN')}`;
@@ -409,9 +539,7 @@ export class RiskReviewReportComponent implements OnInit {
try {
const s = this.summary;
const subtitle =
`Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` +
`Source: live AML monitoring engine` +
(this.sampleCount ? ` + ${this.sampleCount} DEMONSTRATION rows (not compliance data)` : '');
`Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` + `Source: live AML monitoring engine`;
await exportWorkbook(
[
@@ -533,6 +661,7 @@ export class RiskReviewReportComponent implements OnInit {
'Last Alert',
'Rules Point At',
'Recommended Risk',
'Move To',
'Open Cases',
'Position',
'Basis'
@@ -551,6 +680,7 @@ export class RiskReviewReportComponent implements OnInit {
12,
14,
16,
38,
11,
24,
70
@@ -569,6 +699,7 @@ export class RiskReviewReportComponent implements OnInit {
c.lastAlertDate,
RISK_LABEL[c.proposedRisk],
RISK_LABEL[c.recommendedRisk],
this.moveHint(c),
c.openCount,
this.verdictText(c),
c.directionReason || '—'