/** * 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/. */ /** Angular Imports */ import { Component, OnInit, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { forkJoin, of } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; /** Custom Modules */ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; /** Custom Services */ import { AmlService } from '../aml/aml.service'; import { ClientsService } from '../clients/clients.service'; import { BranchScopeService } from '../core/branch-scope.service'; /** Shared report helpers */ import { exportWorkbook } from '../cashier/cashier-export'; import { GL_BRANCHES, GL_PERIOD, glBranchName } from '../gl-reports/gl-reports-data'; /** Report data + computation */ import { AML_RULE_CATALOGUE, LiveAmlAlert, REVIEW_STATUSES, REVIEW_STATUS_META, RISK_LABEL, ReviewStatus, RiskAlertRow, RiskLevel, channelLabel, mapLiveAlert, riskFromCategory, sampleRiskAlerts } from './risk-review-data'; import { DateRange, RatingDirection, RiskAlertView, RiskCustomerRow, RiskReviewFilter, RiskReviewModel, RiskReviewSummary, RiskTxnView, buildRiskReview, monthRange, weekRange } from './risk-review.compute'; type PeriodMode = 'day' | 'week' | 'month' | 'range'; type ViewMode = 'customer' | 'alert' | 'txn'; /** Starting value for the memo, used before the first filter pass runs. */ const EMPTY_SUMMARY: RiskReviewSummary = { customers: 0, alerts: 0, txns: 0, ctrReportable: 0, openAlerts: 0, escalatedCustomers: 0, upgradeCandidates: 0, downgradeCandidates: 0, totalAmount: 0, byRisk: { LOW: 0, MEDIUM: 0, HIGH: 0, UNRATED: 0 }, byRule: [], byStatus: [] }; /** * Risk Review Report — the AML/compliance worklist. * * Lists the customers who tripped an AML rule in the period, the transaction * that tripped it, the rating currently on their KYC record and where the * review has got to. The reviewer's decision on each case is one of: keep the * 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. */ @Component({ selector: 'mifosx-risk-review-report', templateUrl: './risk-review-report.component.html', styleUrls: ['./risk-review-report.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, FaIconComponent, FormsModule ] }) export class RiskReviewReportComponent implements OnInit { private aml = inject(AmlService); private clients = inject(ClientsService); private scope = inject(BranchScopeService); readonly period = GL_PERIOD; readonly branches = GL_BRANCHES; readonly rules = AML_RULE_CATALOGUE; readonly reviewStatuses = REVIEW_STATUSES; readonly statusMeta = REVIEW_STATUS_META; readonly riskLabel = RISK_LABEL; readonly channelLabel = channelLabel; readonly periodModes: { key: PeriodMode; label: string }[] = [ { key: 'day', label: 'Day wise' }, { key: 'week', label: 'Week wise' }, { key: 'month', label: 'Month wise' }, { key: 'range', label: 'Between dates' } ]; // ── Source state ─────────────────────────────────────────────────────────── /** Rows returned by the monitoring engine, mapped onto the report shape. */ 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. */ private alerts: RiskAlertRow[] = []; get hasLive(): boolean { return this.liveAlerts.length > 0; } get sampleCount(): number { return this.alerts.filter((a) => !a.live).length; } // ── Filter state ─────────────────────────────────────────────────────────── /** * Alerts raised today must be in view, so the default window runs to whichever * is later — the seeded period end or the real current date. */ private static readonly TODAY = new Date().toISOString().slice(0, 10); private static readonly WINDOW_END = RiskReviewReportComponent.TODAY > GL_PERIOD.fyEnd ? RiskReviewReportComponent.TODAY : GL_PERIOD.fyEnd; periodMode: PeriodMode = 'range'; filterDay = RiskReviewReportComponent.WINDOW_END; /** Any date inside the wanted week; resolved to Mon–Sat. */ filterWeek = '2026-07-27'; filterMonth = '2026-07'; filterFrom = GL_PERIOD.fyStart; filterTo = RiskReviewReportComponent.WINDOW_END; branchCode = ''; currentRisk: RiskLevel | '' = ''; ruleCode = ''; status: ReviewStatus | '' = ''; /** Which way the review says the rating should move. '' shows every customer. */ direction: RatingDirection | '' = ''; search = ''; openOnly = false; view: ViewMode = 'customer'; /** Client ID of the customer row currently expanded. */ expanded: number | null = null; generating = false; toastMsg = ''; toastVisible = false; private toastTimer: any; ngOnInit(): void { // A branch login opens on its own book and cannot move off it. this.branchCode = this.scope.defaultBranchCode; this.load(); } // ── Loading ──────────────────────────────────────────────────────────────── load(): void { this.loading = true; this.aml .getAlerts() .pipe(catchError(() => of([] as LiveAmlAlert[]))) .subscribe((live) => { const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert)); this.liveAlerts = rows; this.compose(); if (!rows.length) { this.loading = false; return; } this.enrichCurrentRisk(rows); }); } /** Live alerts first, then the demonstration set behind them. */ private compose(): void { this.alerts = [ ...this.liveAlerts, ...sampleRiskAlerts() ]; this.memoKey = ''; } /** * The alert payload carries no rating, so the customer's current risk is read * off their KYC record — one call per distinct customer, not per alert. A * client whose KYC cannot be read stays unrated rather than blocking the * report, so the table renders either way. */ private enrichCurrentRisk(rows: RiskAlertRow[]): void { const clientIds = [...new Set(rows.map((r) => r.clientId))].filter((id) => id != null); if (!clientIds.length) { this.loading = false; return; } forkJoin( clientIds.map((id) => this.clients.getClientKyc(String(id), true).pipe( map((kyc: any) => ({ id, risk: riskFromCategory(kyc?.riskCategory) })), catchError(() => of({ id, risk: null as RiskLevel | null })) ) ) ).subscribe((results) => { const byClient = new Map( results.map((r) => [ r.id, r.risk ]) ); this.liveAlerts = rows.map((r) => ({ ...r, currentRisk: byClient.get(r.clientId) ?? null })); this.compose(); this.loading = false; }); } // ── Filter resolution ────────────────────────────────────────────────────── get range(): DateRange { if (this.periodMode === 'day') { return { from: this.filterDay, to: this.filterDay }; } if (this.periodMode === 'week') { return weekRange(this.filterWeek); } if (this.periodMode === 'month') { return monthRange(this.filterMonth); } return { from: this.filterFrom, to: this.filterTo }; } get rangeLabel(): string { const r = this.range; return r.from === r.to ? r.from : `${r.from} to ${r.to}`; } get canSelectBranch(): boolean { return this.scope.canSelectBranch; } get branchLabel(): string { return this.scope.scopeLabel(this.branchCode); } /** * Applied at the data boundary rather than only in the UI, so a branch login * cannot widen its own scope by tampering with component state. */ private get effectiveBranch(): string { return this.scope.enforce(this.branchCode); } private get filter(): RiskReviewFilter { return { range: this.range, branchCode: this.effectiveBranch, currentRisk: this.currentRisk, ruleCode: this.ruleCode, status: this.status, direction: this.direction, search: this.search, openOnly: this.openOnly }; } resetFilters(): void { this.periodMode = 'range'; this.filterDay = RiskReviewReportComponent.WINDOW_END; this.filterWeek = '2026-07-27'; this.filterMonth = '2026-07'; this.filterFrom = GL_PERIOD.fyStart; this.filterTo = RiskReviewReportComponent.WINDOW_END; this.currentRisk = ''; this.ruleCode = ''; this.status = ''; this.direction = ''; this.search = ''; this.openOnly = false; this.expanded = null; // Reset returns a branch login to its own branch, never to "all". this.branchCode = this.scope.defaultBranchCode; } // ── Data ─────────────────────────────────────────────────────────────────── /** * The whole screen reads from one computed model. Change detection hits these * getters many times per cycle, so the result is memoised against the filter * rather than re-filtering thousands of rows on every binding. */ private memoKey = ''; private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY }; private get model(): RiskReviewModel { const key = JSON.stringify(this.filter) + `|${this.alerts.length}`; if (key !== this.memoKey) { this.memo = buildRiskReview(this.alerts, this.filter); this.memoKey = key; } return this.memo; } get alertRows(): RiskAlertView[] { return this.model.alerts; } get customerRows(): RiskCustomerRow[] { return this.model.customers; } get txnRows(): RiskTxnView[] { return this.model.txns; } get summary(): RiskReviewSummary { return this.model.summary; } toggle(clientId: number): void { this.expanded = this.expanded === clientId ? null : clientId; } /** One-click drill from the rule mix strip into that rule. */ toggleRule(code: string): void { this.ruleCode = this.ruleCode === code ? '' : code; this.expanded = null; } // ── Presentation helpers ─────────────────────────────────────────────────── riskClass(level: RiskLevel | null): string { return level ? `rr-risk rr-risk--${level.toLowerCase()}` : 'rr-risk rr-risk--none'; } riskText(level: RiskLevel | null): string { return level ? RISK_LABEL[level] : 'Unrated'; } statusClass(status: ReviewStatus): string { return `rr-status rr-status--${REVIEW_STATUS_META[status].tone}`; } /** What the review concludes should happen to the rating, in one phrase. */ verdictText(c: RiskCustomerRow): string { if (c.escalated) { return 'Escalated to STR'; } if (c.direction === 'up') { return `Review upward → ${RISK_LABEL[c.recommendedRisk]}`; } if (c.direction === 'down') { return `Review downward → ${RISK_LABEL[c.recommendedRisk]}`; } return 'Within current rating'; } verdictClass(c: RiskCustomerRow): string { if (c.escalated) { return 'rr-verdict rr-verdict--str'; } if (c.direction === 'up') { return 'rr-verdict rr-verdict--up'; } if (c.direction === 'down') { return 'rr-verdict rr-verdict--down'; } return 'rr-verdict'; } /** Transaction detail as the reviewer would read it in one line. */ txnDetail(a: RiskAlertView): string { const amount = `₹${a.amount.toLocaleString('en-IN')}`; if (a.txnCount > 1) { return `${a.txnCount} × ${channelLabel(a.channel)} aggregating ${amount}`; } return `${channelLabel(a.channel)} of ${amount}`; } // ── Excel ────────────────────────────────────────────────────────────────── async exportExcel(): Promise { this.generating = true; 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)` : ''); await exportWorkbook( [ { sheetName: 'Summary', title: 'Risk Review Report — Summary', subtitle, headers: [ 'Particulars', 'Value' ], widths: [ 56, 22 ], rows: [ [ 'Customers flagged for risk review', String(s.customers) ], [ 'AML alerts triggered', String(s.alerts) ], [ 'Transactions behind those alerts', String(s.txns) ], [ ' of which cash entries at or above the ₹10,00,000 CTR limit', String(s.ctrReportable) ], [ 'Alerts still awaiting a reviewer decision', String(s.openAlerts) ], [ 'Customers rated below what the triggered rules point at (review upward)', String(s.upgradeCandidates) ], [ 'Customers rated above what the period’s activity warrants (review downward)', String(s.downgradeCandidates) ], [ 'Customers escalated to the Principal Officer (STR)', String(s.escalatedCustomers) ], [ 'Total transaction value under review', s.totalAmount ], [ '', '' ], [ 'Customers currently rated Low', String(s.byRisk.LOW) ], [ 'Customers currently rated Medium', String(s.byRisk.MEDIUM) ], [ 'Customers currently rated High', String(s.byRisk.HIGH) ], [ 'Customers with no rating on record', String(s.byRisk.UNRATED) ] ] }, { sheetName: 'Rules triggered', title: 'Risk Review Report — AML Rules Triggered', subtitle, headers: [ 'AML Rule Triggered', 'Category', 'Alerts', 'What the rule looks for', 'Action expected of the reviewer' ], widths: [ 34, 14, 10, 62, 62 ], rows: s.byRule.map((r) => { const meta = AML_RULE_CATALOGUE.find((m) => m.code === r.code); return [ r.label, meta?.category ?? '—', r.count, meta?.description ?? '—', meta?.suggestedAction ?? '—' ]; }) }, { sheetName: 'Customer wise', title: 'Risk Review Report — Customer wise', subtitle, headers: [ 'Customer ID', 'Customer Name', 'Account Number', 'Branch', 'Current Risk', 'Alerts', 'AML Rules Triggered', 'Total Amount', 'Largest Alert', 'First Alert', 'Last Alert', 'Rules Point At', 'Recommended Risk', 'Open Cases', 'Position', 'Basis' ], widths: [ 13, 28, 20, 16, 13, 8, 46, 16, 16, 12, 12, 14, 16, 11, 24, 70 ], rows: this.customerRows.map((c) => [ String(c.clientId), c.customerName, c.accountNo || '—', c.branchCode ? glBranchName(c.branchCode) : '—', this.riskText(c.currentRisk), c.alertCount, c.ruleLabels.join('; '), c.totalAmount, c.largestAmount, c.firstAlertDate, c.lastAlertDate, RISK_LABEL[c.proposedRisk], RISK_LABEL[c.recommendedRisk], c.openCount, this.verdictText(c), c.directionReason || '—' ]) }, { sheetName: 'Alert wise', title: 'Risk Review Report — Alert wise', subtitle, headers: [ 'Alert ID', 'Alert Date', 'Customer ID', 'Customer Name', 'Account Number', 'Branch', 'Current Risk', 'Transaction Details', 'AML Rule Triggered', 'Rule Category', 'Amount', 'Review Status', 'Reviewed By', 'Reviewer Remarks', 'Action Expected' ], widths: [ 11, 12, 13, 28, 20, 16, 13, 38, 32, 14, 16, 22, 16, 60, 60 ], rows: this.alertRows.map((a) => [ a.alertId, a.alertDate, String(a.clientId), a.customerName, a.accountNo || '—', a.branchCode ? glBranchName(a.branchCode) : '—', this.riskText(a.currentRisk), this.txnDetail(a), a.rule.label, a.rule.category, a.amount, REVIEW_STATUS_META[a.status].label, a.reviewedBy || '—', a.remarks || '—', a.rule.suggestedAction ]) }, { sheetName: 'Transaction wise', title: 'Risk Review Report — Transaction wise (rule triggered by each entry)', subtitle, headers: [ 'Txn Date', 'Txn ID', 'Customer ID', 'Customer Name', 'Account Number', 'Branch', 'Current Risk', 'Channel', 'Dr/Cr', 'Amount', 'Balance After', 'Narration', 'Counterparty', 'AML Rule Triggered', 'Rule Category', 'Leg', 'Alert ID', 'Alert Date', 'Review Status', 'CTR' ], widths: [ 12, 12, 13, 28, 20, 16, 13, 18, 7, 16, 16, 32, 38, 32, 14, 9, 11, 12, 22, 7 ], rows: this.txnRows.map((t) => [ t.date, t.txnId, String(t.clientId), t.customerName, t.accountNo || '—', t.branchCode ? glBranchName(t.branchCode) : '—', this.riskText(t.currentRisk), channelLabel(t.channel), t.drCr, t.amount, t.balanceAfter || null, t.narration || '—', t.counterparty || '—', t.rule.label, t.rule.category, t.legCount > 1 ? `${t.legIndex} of ${t.legCount}` : '—', t.alertId, t.alertDate, REVIEW_STATUS_META[t.status].label, t.ctrReportable ? 'Yes' : '' ]) } ], `Risk-Review-Report-${this.range.from}-to-${this.range.to}` ); this.toast( `Exported ${s.txns} transactions across ${s.alerts} alerts and ${s.customers} customers for ${this.rangeLabel}` ); } catch { this.toast('Could not generate the workbook — please retry'); } finally { this.generating = false; } } private toast(msg: string): void { this.toastMsg = msg; this.toastVisible = true; if (this.toastTimer) { clearTimeout(this.toastTimer); } this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200); } }