From b43ed87771a93dd29152b7e0b780e6afa0c80c75 Mon Sep 17 00:00:00 2001 From: keshav Date: Sun, 2 Aug 2026 21:32:45 +0530 Subject: [PATCH] risk review report with on which aml rule --- src/app/core/shell/shell.component.html | 11 + src/app/fiu/fiu-data.ts | 310 ++++ src/app/fiu/fiu.component.html | 427 ++--- src/app/fiu/fiu.component.scss | 298 ++++ src/app/fiu/fiu.component.ts | 334 +++- src/app/reports/reports-routing.module.ts | 6 + src/app/risk-review/risk-review-data.ts | 1471 +++++++++++++++++ .../risk-review-report.component.html | 527 ++++++ .../risk-review-report.component.scss | 820 +++++++++ .../risk-review-report.component.ts | 726 ++++++++ src/app/risk-review/risk-review.compute.ts | 489 ++++++ src/app/shared/icons.module.ts | 2 + src/environments/.env.ts | 4 +- 13 files changed, 5098 insertions(+), 327 deletions(-) create mode 100644 src/app/risk-review/risk-review-data.ts create mode 100644 src/app/risk-review/risk-review-report.component.html create mode 100644 src/app/risk-review/risk-review-report.component.scss create mode 100644 src/app/risk-review/risk-review-report.component.ts create mode 100644 src/app/risk-review/risk-review.compute.ts diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 7b92aa74b..2f196530f 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -591,6 +591,9 @@ +
diff --git a/src/app/fiu/fiu-data.ts b/src/app/fiu/fiu-data.ts index dbfb3c457..b423d948e 100644 --- a/src/app/fiu/fiu-data.ts +++ b/src/app/fiu/fiu-data.ts @@ -320,6 +320,316 @@ export const CTR_TXNS: CtrTransaction[] = [ } ]; +/** + * ── TC1: ATM / card transactions ── + * + * TC2 carries what happened at the counter; TC1 carries what happened at the + * card rails. Both feed the same CTR, because an ATM cash withdrawal is still + * a cash transaction and counts towards the customer's monthly aggregate. + * + * Every row here belongs to an account that is already in CTR_TXNS and carries + * the same reportRefNo, so the six FINnet files reconcile against each other. + */ +export interface CardTransaction { + reportType: ReportType; + reportRefNo: number; + branchCode: string; + accountNumber: string; + /** FINnet account-type code, same codes used in Account_Detail. */ + accountType: string; + relationshipFlag: 'Customer' | 'Non-Customer'; + /** Settled at the switch, or declined / reversed. */ + txnStatus: 'Success' | 'Failed' | 'Reversed'; + txnId: number; + txnDate: string; + txnTime: string; + depositOrWithdrawal: 'Deposit' | 'Withdrawal'; + txnAmount: number; + cardType: 'RuPay Debit' | 'VISA Debit' | 'Mastercard Debit'; + /** Masked to the last four digits — the PAN itself is never put in the file. */ + cardNumber: string; + /** FINnet country code; 95 is India, as used across the KC files. */ + cardIssuingCountry: string; + cardIssuingBank: string; + /** NFS / NPCI switch reference for the leg. */ + switchTxnId: string; + atmId: string; + /** UI convenience, not part of the TC1 schema. */ + customerName?: string; +} + +export const TC1_TXNS: CardTransaction[] = [ + // Ref 1 — ANAND KRISHNAN: routine ATM usage alongside a large counter deposit. + { + reportType: 'CTR', + reportRefNo: 1, + branchCode: '1', + accountNumber: '110001101000838', + accountType: '211', + relationshipFlag: 'Customer', + txnStatus: 'Success', + txnId: 9001, + txnDate: '06-01-2026', + txnTime: '19:42:11', + depositOrWithdrawal: 'Withdrawal', + txnAmount: 20000, + cardType: 'RuPay Debit', + cardNumber: 'XXXXXXXXXXXX4417', + cardIssuingCountry: '95', + cardIssuingBank: 'CBSNexora Bank Ltd', + switchTxnId: 'NFS26010600418', + atmId: 'NEXA0001', + customerName: 'ANAND KRISHNAN' + }, + { + reportType: 'CTR', + reportRefNo: 1, + branchCode: '1', + accountNumber: '110001101000838', + accountType: '211', + relationshipFlag: 'Customer', + txnStatus: 'Success', + txnId: 9002, + txnDate: '18-01-2026', + txnTime: '11:07:53', + depositOrWithdrawal: 'Withdrawal', + txnAmount: 25000, + cardType: 'RuPay Debit', + cardNumber: 'XXXXXXXXXXXX4417', + cardIssuingCountry: '95', + cardIssuingBank: 'CBSNexora Bank Ltd', + switchTxnId: 'NFS26011800911', + atmId: 'NEXA0003', + customerName: 'ANAND KRISHNAN' + }, + + // Ref 2 — MEERA SUNDARAM: card cash-out at another bank's ATM. + { + reportType: 'CTR', + reportRefNo: 2, + branchCode: '3', + accountNumber: '110001204000712', + accountType: '215', + relationshipFlag: 'Customer', + txnStatus: 'Success', + txnId: 9003, + txnDate: '09-01-2026', + txnTime: '20:15:36', + depositOrWithdrawal: 'Withdrawal', + txnAmount: 10000, + cardType: 'VISA Debit', + cardNumber: 'XXXXXXXXXXXX8820', + cardIssuingCountry: '95', + cardIssuingBank: 'CBSNexora Bank Ltd', + switchTxnId: 'NFS26010901277', + atmId: 'SBIN004512', + customerName: 'MEERA SUNDARAM' + }, + { + reportType: 'CTR', + reportRefNo: 2, + branchCode: '3', + accountNumber: '110001204000712', + accountType: '215', + relationshipFlag: 'Customer', + // Declined for insufficient balance. Failed legs are still reported: the + // attempt itself is part of the customer's pattern. + txnStatus: 'Failed', + txnId: 9004, + txnDate: '09-01-2026', + txnTime: '20:17:02', + depositOrWithdrawal: 'Withdrawal', + txnAmount: 20000, + cardType: 'VISA Debit', + cardNumber: 'XXXXXXXXXXXX8820', + cardIssuingCountry: '95', + cardIssuingBank: 'CBSNexora Bank Ltd', + switchTxnId: 'NFS26010901284', + atmId: 'SBIN004512', + customerName: 'MEERA SUNDARAM' + }, + + // Ref 3 — HARPREET SINGH BEDI: cash recycler deposit through the card. + { + reportType: 'CTR', + reportRefNo: 3, + branchCode: '6', + accountNumber: '110001307000455', + accountType: '215', + relationshipFlag: 'Customer', + txnStatus: 'Success', + txnId: 9005, + txnDate: '12-01-2026', + txnTime: '13:28:44', + depositOrWithdrawal: 'Deposit', + txnAmount: 49500, + cardType: 'RuPay Debit', + cardNumber: 'XXXXXXXXXXXX6193', + cardIssuingCountry: '95', + cardIssuingBank: 'CBSNexora Bank Ltd', + switchTxnId: 'NFS26011200630', + atmId: 'NEXA0002-CRM', + customerName: 'HARPREET SINGH BEDI' + }, + + // Ref 4 — FAROOQ ABDULLAH KHAN: repeated near-limit withdrawals in one day. + { + reportType: 'CTR', + reportRefNo: 4, + branchCode: '2', + accountNumber: '110001408000933', + accountType: '211', + relationshipFlag: 'Customer', + txnStatus: 'Success', + txnId: 9006, + txnDate: '16-01-2026', + txnTime: '09:03:19', + depositOrWithdrawal: 'Withdrawal', + txnAmount: 50000, + cardType: 'Mastercard Debit', + cardNumber: 'XXXXXXXXXXXX2075', + cardIssuingCountry: '95', + cardIssuingBank: 'CBSNexora Bank Ltd', + switchTxnId: 'NFS26011600142', + atmId: 'NEXA0004', + customerName: 'FAROOQ ABDULLAH KHAN' + }, + { + reportType: 'CTR', + reportRefNo: 4, + branchCode: '2', + accountNumber: '110001408000933', + accountType: '211', + relationshipFlag: 'Customer', + txnStatus: 'Success', + txnId: 9007, + txnDate: '16-01-2026', + txnTime: '09:06:41', + depositOrWithdrawal: 'Withdrawal', + txnAmount: 50000, + cardType: 'Mastercard Debit', + cardNumber: 'XXXXXXXXXXXX2075', + cardIssuingCountry: '95', + cardIssuingBank: 'CBSNexora Bank Ltd', + switchTxnId: 'NFS26011600149', + atmId: 'NEXA0004', + customerName: 'FAROOQ ABDULLAH KHAN' + }, + { + reportType: 'CTR', + reportRefNo: 4, + branchCode: '2', + accountNumber: '110001408000933', + accountType: '211', + relationshipFlag: 'Customer', + // Dispensed short and auto-reversed by the switch the same evening. + txnStatus: 'Reversed', + txnId: 9008, + txnDate: '16-01-2026', + txnTime: '09:09:05', + depositOrWithdrawal: 'Withdrawal', + txnAmount: 50000, + cardType: 'Mastercard Debit', + cardNumber: 'XXXXXXXXXXXX2075', + cardIssuingCountry: '95', + cardIssuingBank: 'CBSNexora Bank Ltd', + switchTxnId: 'NFS26011600155', + atmId: 'NEXA0004', + customerName: 'FAROOQ ABDULLAH KHAN' + }, + + // Ref 5 — DEEPIKA RANGANATHAN + { + reportType: 'CTR', + reportRefNo: 5, + branchCode: '9', + accountNumber: '110001509000268', + accountType: '215', + relationshipFlag: 'Customer', + txnStatus: 'Success', + txnId: 9009, + txnDate: '21-01-2026', + txnTime: '17:51:28', + depositOrWithdrawal: 'Withdrawal', + txnAmount: 15000, + cardType: 'RuPay Debit', + cardNumber: 'XXXXXXXXXXXX9354', + cardIssuingCountry: '95', + cardIssuingBank: 'CBSNexora Bank Ltd', + switchTxnId: 'NFS26012101508', + atmId: 'HDFC009871', + customerName: 'DEEPIKA RANGANATHAN' + }, + + // Ref 6 — RAJENDRA PRASAD YADAV + { + reportType: 'CTR', + reportRefNo: 6, + branchCode: '5', + accountNumber: '110001610000841', + accountType: '215', + relationshipFlag: 'Customer', + txnStatus: 'Success', + txnId: 9010, + txnDate: '23-01-2026', + txnTime: '10:34:12', + depositOrWithdrawal: 'Withdrawal', + txnAmount: 40000, + cardType: 'RuPay Debit', + cardNumber: 'XXXXXXXXXXXX1268', + cardIssuingCountry: '95', + cardIssuingBank: 'CBSNexora Bank Ltd', + switchTxnId: 'NFS26012300287', + atmId: 'NEXA0005', + customerName: 'RAJENDRA PRASAD YADAV' + }, + { + reportType: 'CTR', + reportRefNo: 6, + branchCode: '5', + accountNumber: '110001610000841', + accountType: '215', + relationshipFlag: 'Customer', + txnStatus: 'Success', + txnId: 9011, + txnDate: '28-01-2026', + txnTime: '18:22:47', + depositOrWithdrawal: 'Withdrawal', + txnAmount: 35000, + cardType: 'RuPay Debit', + cardNumber: 'XXXXXXXXXXXX1268', + cardIssuingCountry: '95', + cardIssuingBank: 'CBSNexora Bank Ltd', + switchTxnId: 'NFS26012801733', + atmId: 'NEXA0005', + customerName: 'RAJENDRA PRASAD YADAV' + }, + + // Ref 7 — SHABANA TARIQ MIRZA: the account's card is used by a third party, + // which is why the relationship flag differs from the rest. + { + reportType: 'CTR', + reportRefNo: 7, + branchCode: '12', + accountNumber: '110001711000527', + accountType: '215', + relationshipFlag: 'Non-Customer', + txnStatus: 'Success', + txnId: 9012, + txnDate: '27-01-2026', + txnTime: '21:08:33', + depositOrWithdrawal: 'Withdrawal', + txnAmount: 30000, + cardType: 'VISA Debit', + cardNumber: 'XXXXXXXXXXXX7741', + cardIssuingCountry: '95', + cardIssuingBank: 'CBSNexora Bank Ltd', + switchTxnId: 'NFS26012702094', + atmId: 'ICIC007233', + customerName: 'SHABANA TARIQ MIRZA' + } +]; + /** * ── STR cases ── * Raised either by the AML rules engine or by an officer flagging a CTR row as diff --git a/src/app/fiu/fiu.component.html b/src/app/fiu/fiu.component.html index 21769e2ff..a4c1ce3f9 100644 --- a/src/app/fiu/fiu.component.html +++ b/src/app/fiu/fiu.component.html @@ -109,6 +109,52 @@
+ +
+
+
+

When to report to FIU-IND

+

+ The five reports a reporting entity owes under the Prevention of Money Laundering Act. All are filed + electronically through the FINnet 2.0 portal. +

+
+
+ +
+ @for (o of obligations; track o.code) { +
+
+ + {{ o.code }} + {{ o.name }} + @if (o.handled) { + Filed here + } @else { + Tracked + } +
+
+ {{ o.trigger }} +
+
+ {{ o.deadline }} +
+
{{ o.note }}
+ @if (o.code === 'CTR') { + + } @else if (o.code === 'STR') { + + } +
+ } +
+
+
@@ -149,303 +195,106 @@ @if (activeTab === 'ctr') {
- -
-
-

Live CTR Threshold Alerts

-

- Mechanical cash threshold breaches. CTR is filed regardless of outcome — where the source of funds cannot be - evidenced, send the transaction to STR for the Principal Officer to investigate. -

-
- -
-
- - - - - - - - - - - - - - - - - @for (alert of ctrAlerts; track alert.id) { - - - - - - - - - - - - - } @empty { - - - - } - -
IDClientTypeRuleAmountRaised OnStatusRoutes ToNotesAction
{{ alert.id }}{{ alert.clientDisplayName }}{{ alert.transactionType }}{{ alert.ruleCode }} — {{ alert.ruleDescription }}₹{{ alert.amount | number: '1.0-0' }}{{ alert.raisedOn | date: 'medium' }} - {{ alert.status }} - - {{ alertRouteLabel(alert.status) }} - - @if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') { - - } @else { - {{ alert.reviewNotes }} - } - - @if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') { -
- -
- } -
No live alerts.
-
-

- Pending cash breach awaiting review · - Referred to STR source of funds not evidenced — sent to the - Principal Officer, who decides there whether to whitelist it or file an STR -

- - -
-
-

Monthly CTR Reports

-

- Threshold: ₹{{ bank.cashThreshold | number }} per single txn / connected aggregate - per month -

-
-
- -
-
-
- - - - - - - - - - - - @for (r of amlCtrReports; track r.id) { - - - - - - - - } @empty { - - - + + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ @for (f of finnetSequence; track f.key) { + + @if (!$last) { + } -
-
MonthGenerated OnRecordsStatusSubmitted On
{{ r.reportMonth }}{{ r.generatedOn | date: 'medium' }}{{ r.recordCount }} - {{ r.status }} - {{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}
No CTR reports generated yet.
-
+ } +
- -
-
-

FINnet TC2 Export Template

-

- All cash transactions ≥ ₹{{ bank.cashThreshold | number }} (single or aggregate per month per account) -

-
- -
+ +
+
+
+

Step {{ openFinnetFile.step }} — {{ openFinnetFile.title }}

+

{{ openFinnetFile.purpose }}

+
+ +
-
-
- - -
-
- - -
-
- - -
- -
- -
- - - - - - - - - - - - - - - - - - @for (t of filteredCtr; track t.txnId) { - - - - - - - - - - - + @for (r of openFinnetFile.table.rows; track $index) { + + + @for (c of r; track $index) { + + } + + } @empty { + + + + } + +
RefAccountCustomerTypeDate / TimeAmountInstrumentBranchRelationshipFiled UnderCompliance Action
{{ t.reportRefNo }}{{ t.accountNumber }}{{ t.customerName }} - - {{ t.depositOrWithdrawal }} - - - {{ t.txnDate }} {{ t.txnTime }} - - ₹{{ t.txnAmount | number: '1.0-0' }} - - {{ t.instrumentType }} {{ t.instrumentId }} - - {{ t.branchCode }} {{ branchNameFor(t.branchCode) }} - - {{ - t.relationshipFlag - }} - - CTR - @if (t.complianceStatus === 'REFERRED') { - + STR +
+ + + + + @for (h of openFinnetFile.table.headers; track $index) { + } - - - - - - @if (evidencingTxn?.txnId === t.txnId) { - - - } - } @empty { - - - - } - -
#{{ h }} - @if (t.complianceStatus === 'REFERRED') { - REFERRED TO STR - - } @else if (t.complianceStatus === 'EVIDENCED') { - EVIDENCE ON FILE -
- {{ t.evidenceDocType }} - @if (t.evidenceReference) { - · {{ t.evidenceReference }} - } -
- } @else { -
- - -
- } -
-
-
- - Record source-of-funds evidence for {{ t.customerName }} — ₹{{ - t.txnAmount | number: '1.0-0' - }} -
-
-
- - -
-
- - -
-
- - -
-
-

- Recording evidence is a documentation step, not a suspicion verdict. The transaction is reported - in the CTR either way — this simply confirms no STR referral is needed. -

-
-
No transactions match the filters.
+ +
{{ $index + 1 }}{{ c }}
+ @if (openFinnetFile.key === 'tc1') { + No ATM or card transactions match the current scope. When the bank books none at all, TC1 is + still submitted as a header-only file — that is what FIU-IND expects. + } @else { + No rows for the current filters. + } +
+
+
-

- CTR every cash txn ≥ threshold is reported here regardless of - outcome. Record evidence when the customer produces valid documents — CTR only, no STR · - + STR Send to STR when the source of funds cannot - be evidenced; the Principal Officer investigates and decides whether to file with FIU-IND. A referred - transaction stays in the CTR file as well. -

} diff --git a/src/app/fiu/fiu.component.scss b/src/app/fiu/fiu.component.scss index 7651cf52e..dbac07b0c 100644 --- a/src/app/fiu/fiu.component.scss +++ b/src/app/fiu/fiu.component.scss @@ -1549,3 +1549,301 @@ color: var(--f-g500); } } + +/* ═══ WHEN TO REPORT — the five PMLA obligations ═══ */ +.ob-block { + margin: 18px 0 20px; +} + +.ob-head { + margin-bottom: 12px; +} + +.ob-title { + margin: 0; + font-size: 15px; + font-weight: 700; + display: flex; + align-items: center; + gap: 8px; +} + +.ob-sub { + margin: 3px 0 0; + font-size: 12px; + color: #64748b; + max-width: 860px; +} + +.ob-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 12px; +} + +.ob-card { + background: #fff; + border: 1px solid #e2e8f0; + border-left: 4px solid #1a237e; + border-radius: 11px; + padding: 13px 15px; + display: flex; + flex-direction: column; + gap: 7px; + + /* Obligations the module only tracks read back a shade quieter than the + two it actually files, so the distinction is visible at a glance. */ + &--off { + border-left-color: #cbd5e1; + background: #fcfcfd; + } +} + +.ob-card-h { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.ob-ic { + color: #1a237e; + font-size: 15px; +} + +.ob-code { + font-weight: 800; + font-size: 13px; + letter-spacing: 0.02em; +} + +.ob-name { + font-size: 12px; + color: #334155; + font-weight: 600; +} + +.ob-flag { + margin-left: auto; + font-size: 10px; + font-weight: 700; + padding: 1px 8px; + border-radius: 999px; + background: #f1f5f9; + color: #64748b; + white-space: nowrap; + + &--on { + background: #dcfce7; + color: #15803d; + } +} + +.ob-row { + display: flex; + flex-direction: column; + gap: 1px; + + label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; + } + + span { + font-size: 12.5px; + color: #0f172a; + line-height: 1.45; + } +} + +.ob-due { + font-weight: 700; + color: #b45309 !important; +} + +.ob-note { + font-size: 11.5px; + color: #64748b; + font-style: italic; + border-top: 1px dashed #e2e8f0; + padding-top: 6px; +} + +/* ═══ FINNET FILE SEQUENCE ═══ */ +.seq-block { + margin-top: 26px; + padding-top: 18px; + border-top: 2px solid #e2e8f0; +} + +.seq-rail { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + margin-bottom: 14px; +} + +.seq-step { + display: inline-flex; + align-items: center; + gap: 9px; + padding: 7px 13px; + border: 1px solid #cbd5e1; + background: #fff; + border-radius: 9px; + cursor: pointer; + font-family: inherit; + text-align: left; + transition: all 0.15s; + + &:hover { + border-color: #1a237e; + } + + &.active { + background: #1a237e; + border-color: #1a237e; + + .seq-n { + background: #fff; + color: #1a237e; + } + + .seq-txt strong, + .seq-txt em { + color: #fff; + } + } +} + +.seq-n { + display: inline-flex; + align-items: center; + justify-content: center; + width: 21px; + height: 21px; + border-radius: 50%; + background: #1a237e; + color: #fff; + font-size: 11px; + font-weight: 800; + flex: 0 0 21px; +} + +.seq-txt { + display: flex; + flex-direction: column; + line-height: 1.25; + + strong { + font-size: 12.5px; + color: #0f172a; + font-weight: 700; + } + + em { + font-size: 10.5px; + color: #64748b; + font-style: normal; + } +} + +.seq-arrow { + color: #cbd5e1; + font-size: 11px; +} + +.seq-panel { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 11px; + overflow: hidden; +} + +.seq-panel-h { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + padding: 12px 16px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + flex-wrap: wrap; + + h4 { + margin: 0; + font-size: 13.5px; + font-weight: 700; + } + + p { + margin: 2px 0 0; + font-size: 12px; + color: #64748b; + max-width: 780px; + } +} + +.seq-tbl-wrap { + max-height: 460px; + overflow: auto; +} + +.seq-tbl { + width: 100%; + border-collapse: collapse; + font-size: 11.5px; + white-space: nowrap; + + th { + position: sticky; + top: 0; + z-index: 1; + text-align: left; + padding: 8px 10px; + background: #eef2f7; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #334155; + border-bottom: 1px solid #cbd5e1; + } + + td { + padding: 6px 10px; + border-bottom: 1px solid #f1f5f9; + font-family: 'DM Mono', monospace; + color: #0f172a; + } + + tbody tr:hover td { + background: #f8fafc; + } +} + +.seq-rownum { + color: #94a3b8 !important; + font-weight: 700; + text-align: right; + position: sticky; + left: 0; + background: #fff; +} + +.seq-tbl thead .seq-rownum { + background: #eef2f7; +} + +.seq-empty { + padding: 36px !important; + text-align: center; + color: #64748b; + font-family: inherit !important; + white-space: normal; + font-size: 12.5px; +} diff --git a/src/app/fiu/fiu.component.ts b/src/app/fiu/fiu.component.ts index 1bc47c074..aa311c96a 100644 --- a/src/app/fiu/fiu.component.ts +++ b/src/app/fiu/fiu.component.ts @@ -15,8 +15,10 @@ import { AuthenticationService } from 'app/core/authentication/authentication.se import { BRANCHES, BranchMaster, + CardTransaction, CtrTransaction, CTR_TXNS, + TC1_TXNS, FIU_BANK, FIU_BRANCH_CODE_TO_CANONICAL, KC1_INDIVIDUALS, @@ -29,6 +31,90 @@ import { STR_TXNS } from './fiu-data'; +/** A FINnet file rendered as a grid — the same rows that go into the CSV. */ +export interface FinnetTable { + headers: string[]; + rows: string[][]; +} + +export interface FinnetFile { + key: string; + /** File name without the extension, as FIU-IND names it. */ + name: string; + /** Position in the prescribed submission order. */ + step: number; + title: string; + purpose: string; + table: FinnetTable; +} + +/** + * The five reports a reporting entity owes FIU-IND under the PMLA, with the + * trigger for each. All of them are filed electronically through FINnet 2.0. + */ +export interface FiuObligation { + code: string; + name: string; + trigger: string; + deadline: string; + icon: string; + /** Whether this module currently files the report or only tracks the rule. */ + handled: boolean; + note: string; +} + +export const FIU_OBLIGATIONS: FiuObligation[] = [ + { + code: 'CTR', + name: 'Cash Transaction Report', + trigger: + 'Cash transactions exceeding ₹10,00,000 in a calendar month — a single transaction or a series of connected ones.', + deadline: 'By the 15th of the following month', + icon: 'coins', + handled: true, + note: 'Filed from the CTR tab as the six-file FINnet bundle.' + }, + { + code: 'STR', + name: 'Suspicious Transaction Report', + trigger: + 'Any transaction that gives rise to a reasonable ground of suspicion of money laundering or a related offence — regardless of amount, and whether or not it was completed.', + deadline: 'Within 7 working days of forming the suspicion', + icon: 'exclamation-triangle', + handled: true, + note: 'Filed from the STR tab as FINnet STR XML.' + }, + { + code: 'CBWTR', + name: 'Cross-Border Wire Transfer Report', + trigger: + 'Cross-border wire transfers exceeding ₹5,00,000, where the origin or destination of the funds is outside India.', + deadline: 'By the 15th of the following month', + icon: 'globe', + handled: false, + note: 'Not applicable — this bank holds no AD licence and books no cross-border wires.' + }, + { + code: 'CCR', + name: 'Counterfeit Currency Report', + trigger: + 'Any forged or counterfeit currency note detected, at the counter or in a remittance. No monetary threshold applies.', + deadline: 'By the 15th of the following month', + icon: 'money-bill-wave', + handled: false, + note: 'Raised from the branch counterfeit-note register; not yet wired into this module.' + }, + { + code: 'NTR', + name: 'Non-Profit Organisation Transaction Report', + trigger: 'Receipts by a non-profit organisation exceeding ₹10,00,000, or its equivalent in foreign currency.', + deadline: 'By the 15th of the following month', + icon: 'hand-holding-heart', + handled: false, + note: 'Depends on NPO flagging in the customer master; not yet wired into this module.' + } +]; + @Component({ selector: 'mifosx-fiu', templateUrl: './fiu.component.html', @@ -48,8 +134,12 @@ export class FiuComponent implements OnInit { /** Active tab */ activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard'; + /** The five PMLA reporting obligations, shown on the FIU landing tab. */ + readonly obligations = FIU_OBLIGATIONS; + bank = FIU_BANK; ctrTxns: CtrTransaction[] = CTR_TXNS; + tc1Txns: CardTransaction[] = TC1_TXNS; strTxns: StrTransaction[] = STR_TXNS; kc1: KycIndividual[] = KC1_INDIVIDUALS; kc2: KycEntity[] = KC2_ENTITIES; @@ -748,6 +838,17 @@ export class FiuComponent implements OnInit { }); } + /** ATM / card legs under the same scope controls, so TC1 and TC2 stay in step. */ + get filteredTc1(): CardTransaction[] { + const acc = this.ctrFilterAccount.trim(); + return this.tc1Txns.filter((c) => { + if (this.ctrFilterMode && c.depositOrWithdrawal !== this.ctrFilterMode) return false; + if (acc && !c.accountNumber.includes(acc)) return false; + if (this.ctrFilterMinAmount > 0 && c.txnAmount < this.ctrFilterMinAmount) return false; + return true; + }); + } + get filteredStr(): StrTransaction[] { return this.scopedStrTxns.filter((s) => { if (this.strFilterStatus && s.status !== this.strFilterStatus) return false; @@ -797,9 +898,14 @@ export class FiuComponent implements OnInit { return map[c]; } - // -------------------- Exports -------------------- - /** TC2 — Branch / cash transaction CSV */ - exportTc2(): void { + // -------------------- FINnet file builders -------------------- + /** + * Each FINnet file is built once, here, and used for both the on-screen + * preview and the CSV download. Keeping a single builder per file is what + * guarantees the branch sees on screen exactly what is submitted — the two + * used to be written out separately, which let them drift. + */ + private buildTc2(): FinnetTable { const headers = [ 'Report Type', 'Report Reference Number', @@ -818,7 +924,7 @@ export class FiuComponent implements OnInit { 'Third Party PAN (Non-Customer)', 'Declaration (If Third Party PAN (Non-Customer) is not available)' ]; - const rows = [headers]; + const rows: string[][] = []; for (const t of this.filteredCtr) { rows.push([ t.reportType, @@ -839,12 +945,11 @@ export class FiuComponent implements OnInit { t.declaration ]); } - this.saveCsv(rows, 'TC2.csv'); - this.toast(`TC2 exported (${this.filteredCtr.length} rows)`); + return { headers, rows }; } - /** TC1 header-only template (UI doesn't track ATM/card txns) */ - exportTc1(): void { + /** TC1 — ATM / card transactions. Header-only: this bank books no card txns. */ + private buildTc1(): FinnetTable { const headers = [ 'Relationship Flag', 'Transaction Status', @@ -865,12 +970,34 @@ export class FiuComponent implements OnInit { 'Branch Code of Account', 'Report Type' ]; - this.saveCsv([headers], 'TC1.csv'); - this.toast('TC1 template exported (header only)'); + const rows: string[][] = []; + for (const c of this.filteredTc1) { + rows.push([ + c.relationshipFlag, + c.txnStatus, + c.txnId.toString(), + c.txnDate.replace(/-/g, '/'), + c.txnTime, + c.depositOrWithdrawal, + c.txnAmount.toString(), + c.accountNumber, + c.accountType, + c.cardType, + c.cardNumber, + c.cardIssuingCountry, + c.cardIssuingBank, + c.reportRefNo.toString(), + c.switchTxnId, + c.atmId, + c.branchCode, + c.reportType + ]); + } + return { headers, rows }; } /** KC1 — Individual customers */ - exportKc1(): void { + private buildKc1(): FinnetTable { const headers = [ 'Report Reference Number', 'UCIC', @@ -917,7 +1044,7 @@ export class FiuComponent implements OnInit { 'Other Identifier Number', 'Primary Entity' ]; - const rows = [headers]; + const rows: string[][] = []; for (const k of this.kc1) { rows.push([ k.reportRefNo.toString(), @@ -966,12 +1093,11 @@ export class FiuComponent implements OnInit { 'Yes' ]); } - this.saveCsv(rows, 'KC1.csv'); - this.toast(`KC1 exported (${this.kc1.length} individuals)`); + return { headers, rows }; } /** KC2 — Entity customers */ - exportKc2(): void { + private buildKc2(): FinnetTable { const headers = [ 'Report Reference Number', 'UCIC', @@ -1006,7 +1132,7 @@ export class FiuComponent implements OnInit { 'UCIC of Owner of Sole Proprietor Firm', 'Name of Owner of Sole Proprietor Firm' ]; - const rows = [headers]; + const rows: string[][] = []; for (const k of this.kc2) { rows.push([ k.reportRefNo.toString(), @@ -1043,12 +1169,11 @@ export class FiuComponent implements OnInit { k.ownerName ]); } - this.saveCsv(rows, 'KC2.csv'); - this.toast(`KC2 exported (${this.kc2.length} entities)`); + return { headers, rows }; } - /** Account_Detail.csv — per-report account aggregates (12-month) */ - exportAccountDetail(): void { + /** Account_Detail — per-report account aggregates (12-month) */ + private buildAccountDetail(): FinnetTable { const headers = [ 'Report Type', 'Report Reference Number', @@ -1067,7 +1192,7 @@ export class FiuComponent implements OnInit { 'Total Cash Deposit (In last 12 months)', 'Total Cash Withdrawal (In last 12 months) Amount' ]; - const rows = [headers]; + const rows: string[][] = []; // Build one aggregate row per (reportRefNo, accountNumber) pair const seen = new Set(); for (const t of this.filteredCtr) { @@ -1105,12 +1230,11 @@ export class FiuComponent implements OnInit { .toString() ]); } - this.saveCsv(rows, 'Account_Detail.csv'); - this.toast(`Account_Detail exported (${rows.length - 1} accounts)`); + return { headers, rows }; } - /** Account_Person_Relation.csv — account ↔ customer mapping */ - exportAccountPersonRelation(): void { + /** Account_Person_Relation — account ↔ customer mapping */ + private buildAccountPersonRelation(): FinnetTable { const headers = [ 'Report Type', 'Report Reference Number', @@ -1121,7 +1245,7 @@ export class FiuComponent implements OnInit { 'Individual / Non-individual', 'Name of Non-customer' ]; - const rows = [headers]; + const rows: string[][] = []; const seen = new Set(); for (const t of this.filteredCtr) { const key = `${t.reportRefNo}|${t.accountNumber}`; @@ -1140,8 +1264,145 @@ export class FiuComponent implements OnInit { t.relationshipFlag === 'Non-Customer' ? t.customerName || '' : '' ]); } - this.saveCsv(rows, 'Account_Person_Relation.csv'); - this.toast(`Account_Person_Relation exported (${rows.length - 1} rows)`); + return { headers, rows }; + } + + // -------------------- FINnet file sequence -------------------- + + /** + * The six CTR files in the order FIU-IND expects them: the account is + * described first, then who is behind it, then the transactions, and finally + * the customer cards those transactions belong to. + */ + private get allFinnetFiles(): FinnetFile[] { + return [ + { + key: 'account-detail', + name: 'Account_Detail', + step: 1, + title: 'Account Detail', + purpose: + 'One row per reported account — status, opening date and the 12-month debit, credit and cash aggregates.', + table: this.buildAccountDetail() + }, + { + key: 'account-person-relation', + name: 'Account_Person_Relation', + step: 2, + title: 'Account ↔ Person Relation', + purpose: + 'Links each account to the customers behind it, with the relationship type and whether they are an individual or an entity.', + table: this.buildAccountPersonRelation() + }, + { + key: 'tc1', + name: 'TC1', + step: 3, + title: 'TC1 — ATM / Card Transactions', + purpose: + 'The card-rail leg of the same CTR: ATM withdrawals, recycler deposits and declined attempts. An ATM cash withdrawal is still cash, so it counts towards the customer’s monthly aggregate alongside TC2.', + table: this.buildTc1() + }, + { + key: 'tc2', + name: 'TC2', + step: 4, + title: 'TC2 — Branch / Cash Transactions', + purpose: 'The transaction detail itself — every cash transaction that put the account over the CTR threshold.', + table: this.buildTc2() + }, + { + key: 'kc1', + name: 'KC1', + step: 5, + title: 'KC1 — Individual Customer Cards', + purpose: 'Full KYC card for every individual named in the report: identifiers, address, income, risk level.', + table: this.buildKc1() + }, + { + key: 'kc2', + name: 'KC2', + step: 6, + title: 'KC2 — Entity Customer Cards', + purpose: 'The same for non-individuals: firms, HUFs and societies, with GSTIN and sole-proprietor ownership.', + table: this.buildKc2() + } + ]; + } + + /** + * The files walked through in the CTR tab: account, then who is behind it, + * then the two transaction files. The customer cards are excluded here and + * live on the Customer Cards tab instead, though they still travel with the + * full bundle. + */ + get finnetSequence(): FinnetFile[] { + return this.allFinnetFiles.filter((f) => f.key !== 'kc1' && f.key !== 'kc2'); + } + + /** Which file in the sequence is open in the CTR tab. */ + activeFinnetFile = 'account-detail'; + + get openFinnetFile(): FinnetFile { + return this.finnetSequence.find((f) => f.key === this.activeFinnetFile) ?? this.finnetSequence[0]; + } + + showFinnetFile(key: string): void { + this.activeFinnetFile = key; + } + + /** Downloads whichever file is currently previewed, so the button matches the table. */ + downloadOpenFinnetFile(): void { + const f = this.openFinnetFile; + this.saveCsv( + [ + f.table.headers, + ...f.table.rows + ], + `${f.name}.csv` + ); + this.toast(`${f.name} exported (${f.table.rows.length} rows)`); + } + + // -------------------- Exports -------------------- + + private saveTable(file: FinnetFile): void { + this.saveCsv( + [ + file.table.headers, + ...file.table.rows + ], + `${file.name}.csv` + ); + this.toast(`${file.name} exported (${file.table.rows.length} rows)`); + } + + private fileByKey(key: string): FinnetFile { + return this.allFinnetFiles.find((f) => f.key === key)!; + } + + exportTc1(): void { + this.saveTable(this.fileByKey('tc1')); + } + + exportTc2(): void { + this.saveTable(this.fileByKey('tc2')); + } + + exportKc1(): void { + this.saveTable(this.fileByKey('kc1')); + } + + exportKc2(): void { + this.saveTable(this.fileByKey('kc2')); + } + + exportAccountDetail(): void { + this.saveTable(this.fileByKey('account-detail')); + } + + exportAccountPersonRelation(): void { + this.saveTable(this.fileByKey('account-person-relation')); } /** STR XML stub (FINnet 2.0 STR XML) */ @@ -1187,15 +1448,16 @@ export class FiuComponent implements OnInit { URL.revokeObjectURL(url); } - /** Bundle: the 6 CSVs plus the STR XML — emitted as separate downloads sequentially */ + /** + * Bundle: the 6 CSVs plus the STR XML, emitted as separate downloads in the + * order FIU-IND expects them — account, then relation, then TC, then KC. + */ exportAll(): void { - setTimeout(() => this.exportTc1(), 0); - setTimeout(() => this.exportTc2(), 200); - setTimeout(() => this.exportKc1(), 400); - setTimeout(() => this.exportKc2(), 600); - setTimeout(() => this.exportAccountDetail(), 800); - setTimeout(() => this.exportAccountPersonRelation(), 1000); - setTimeout(() => this.exportStrXml(), 1200); + // All six travel together — the customer cards are not walked through in the + // CTR tab, but a bundle without them would be incomplete. + const files = this.allFinnetFiles; + files.forEach((f, i) => setTimeout(() => this.saveTable(f), i * 200)); + setTimeout(() => this.exportStrXml(), files.length * 200); } private escapeXml(s: string): string { diff --git a/src/app/reports/reports-routing.module.ts b/src/app/reports/reports-routing.module.ts index 4d2f7cafc..602c49d5f 100644 --- a/src/app/reports/reports-routing.module.ts +++ b/src/app/reports/reports-routing.module.ts @@ -53,6 +53,12 @@ const routes: Routes = [ (m) => m.FormDeclarationReportComponent ) }, + { + path: 'risk-review', + data: { title: 'Risk Review Report', breadcrumb: 'Risk Review Report' }, + loadComponent: () => + import('../risk-review/risk-review-report.component').then((m) => m.RiskReviewReportComponent) + }, { path: ':filter', data: { routeParamBreadcrumb: 'filter' }, diff --git a/src/app/risk-review/risk-review-data.ts b/src/app/risk-review/risk-review-data.ts new file mode 100644 index 000000000..594b9b9fd --- /dev/null +++ b/src/app/risk-review/risk-review-data.ts @@ -0,0 +1,1471 @@ +/** + * 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 { GL_BRANCHES, GL_PERIOD } from '../gl-reports/gl-reports-data'; + +/** + * Data layer for the Risk Review Report. + * + * The report answers one question for the AML/compliance desk: which customers + * tripped an AML rule in the period, on what transaction, and what does the + * reviewer intend to do about their risk rating. It is not itself a regulatory + * return — it is the internal worklist that feeds the risk re-rating, the + * re-KYC and, where warranted, the STR. + * + * Rows come from the live rule engine (GET /aml/alerts, mapped by + * `mapLiveAlert`). Where no live alert exists the screen falls back to the + * deterministic sample set below so the report can still be demonstrated; the + * component labels that state explicitly so sample rows are never mistaken for + * live compliance data. + */ + +// ── Risk rating ────────────────────────────────────────────────────────────── + +export type RiskLevel = 'LOW' | 'MEDIUM' | 'HIGH'; + +export const RISK_LABEL: Record = { + LOW: 'Low', + MEDIUM: 'Medium', + HIGH: 'High' +}; + +/** m_client_kyc.risk_category is stored as 1/2/3; anything else is unrated. */ +export function riskFromCategory(category: number | null | undefined): RiskLevel | null { + switch (category) { + case 1: + return 'LOW'; + case 2: + return 'MEDIUM'; + case 3: + return 'HIGH'; + default: + return null; + } +} + +export function riskRank(level: RiskLevel | null): number { + return level === 'HIGH' ? 3 : level === 'MEDIUM' ? 2 : level === 'LOW' ? 1 : 0; +} + +// ── Review workflow ────────────────────────────────────────────────────────── + +/** + * What the reviewer decided. The first two are open states; the rest are the + * four outcomes a risk review can actually have — keep the rating, move it, + * refresh KYC, or push the case on to the Principal Officer for an STR. + */ +export type ReviewStatus = + | 'PENDING' + | 'IN_REVIEW' + | 'RISK_RETAINED' + | 'RISK_UPGRADED' + | 'KYC_UPDATE' + | 'ESCALATED_STR' + | 'STR_FILED'; + +export interface ReviewStatusMeta { + label: string; + /** Drives the badge colour — see .rr-status--* in the stylesheet. */ + tone: 'open' | 'progress' | 'ok' | 'warn' | 'danger'; + /** Whether the case still needs a reviewer to act. */ + open: boolean; +} + +export const REVIEW_STATUS_META: Record = { + PENDING: { label: 'Pending review', tone: 'open', open: true }, + IN_REVIEW: { label: 'Under review', tone: 'progress', open: true }, + RISK_RETAINED: { label: 'Risk retained', tone: 'ok', open: false }, + RISK_UPGRADED: { label: 'Risk upgraded', tone: 'warn', open: false }, + KYC_UPDATE: { label: 'KYC update called for', tone: 'warn', open: false }, + ESCALATED_STR: { label: 'Escalated — STR case', tone: 'danger', open: false }, + STR_FILED: { label: 'STR filed with FIU-IND', tone: 'danger', open: false } +}; + +export const REVIEW_STATUSES = Object.keys(REVIEW_STATUS_META) as ReviewStatus[]; + +/** + * Maps the alert engine's own lifecycle onto the review vocabulary above, so a + * live alert and a sample row read the same way in the table. + */ +export function reviewStatusFromAlert(status: string, disposition: string | null): ReviewStatus { + if (disposition === 'ESCALATE') { + return 'ESCALATED_STR'; + } + if (disposition === 'WHITELIST') { + return 'RISK_RETAINED'; + } + switch (status) { + case 'UNDER_REVIEW': + return 'IN_REVIEW'; + case 'ESCALATED': + return 'ESCALATED_STR'; + case 'CLOSED': + case 'WHITELISTED': + return 'RISK_RETAINED'; + default: + return 'PENDING'; + } +} + +// ── AML rule catalogue ─────────────────────────────────────────────────────── + +export type RuleCategory = 'Cash' | 'Pattern' | 'Cross-border' | 'Behavioural' | 'Profile' | 'Screening'; + +export interface AmlRuleMeta { + code: string; + label: string; + category: RuleCategory; + /** What the rule looks for, in the words a reviewer would use. */ + description: string; + /** The standing instruction for a reviewer who picks this alert up. */ + suggestedAction: string; + /** Risk the rule points at when it fires — the rating to review against. */ + impliedRisk: RiskLevel; +} + +export const AML_RULE_CATALOGUE: AmlRuleMeta[] = [ + { + code: 'LARGE_CASH_TXN', + label: 'Large Cash Transaction', + category: 'Cash', + description: 'A single cash deposit or withdrawal at or above the ₹10,00,000 CTR threshold.', + suggestedAction: 'Verify the source of funds and confirm the transaction is carried in the monthly CTR.', + impliedRisk: 'MEDIUM' + }, + { + code: 'STRUCTURING', + label: 'Structuring (Threshold Avoidance)', + category: 'Pattern', + description: + 'A series of cash transactions kept just under the reporting threshold but integrally connected within the month.', + suggestedAction: 'Aggregate the series. Deliberate threshold avoidance is reportable irrespective of amount.', + impliedRisk: 'HIGH' + }, + { + code: 'HIGH_RISK_COUNTRY', + label: 'High-Risk Country Transaction', + category: 'Cross-border', + description: 'Funds sent to or received from a FATF high-risk or increased-monitoring jurisdiction.', + suggestedAction: 'Obtain the purpose and full beneficiary details; apply enhanced due diligence.', + impliedRisk: 'HIGH' + }, + { + code: 'DORMANT_ACTIVATION', + label: 'Dormant Account Activation', + category: 'Behavioural', + description: 'An account dormant beyond the inactivity period suddenly carrying heavy transfers.', + suggestedAction: 'Re-verify the customer in person before releasing further debits; check for account takeover.', + impliedRisk: 'HIGH' + }, + { + code: 'UNUSUAL_PATTERN', + label: 'Unusual Transaction Pattern', + category: 'Profile', + description: 'Turnover materially above the income or turnover the customer declared at KYC.', + suggestedAction: 'Reconcile against declared income and refresh the KYC record.', + impliedRisk: 'MEDIUM' + }, + { + code: 'VOLUME_SPIKE', + label: 'Sudden Increase in Transaction Volume', + category: 'Behavioural', + description: 'Monthly volume several times the customer’s own established running average.', + suggestedAction: 'Ask the branch for the business reason behind the change in activity.', + impliedRisk: 'MEDIUM' + }, + { + code: 'THIRD_PARTY', + label: 'Frequent Third-Party Transfers', + category: 'Pattern', + description: 'Repeated credits from, or debits to, parties with no apparent relationship to the customer.', + suggestedAction: 'Establish the relationship with each counterparty; watch for money-mule behaviour.', + impliedRisk: 'MEDIUM' + }, + { + code: 'RAPID_MOVEMENT', + label: 'Rapid Movement of Funds (Pass-through)', + category: 'Pattern', + description: 'Large credits withdrawn or moved on almost immediately, leaving no working balance.', + suggestedAction: 'Treat as a possible layering account; obtain an explanation before further credits.', + impliedRisk: 'HIGH' + }, + { + code: 'CROSS_BORDER', + label: 'International Remittance', + category: 'Cross-border', + description: 'Inward or outward foreign remittance outside the customer’s usual pattern.', + suggestedAction: 'Check the purpose code and FEMA declaration against the customer profile.', + impliedRisk: 'MEDIUM' + }, + { + code: 'PEP', + label: 'Politically Exposed Person', + category: 'Profile', + description: 'Customer, or a close relative or associate, identified as politically exposed.', + suggestedAction: 'Senior management approval is required to continue the relationship; review annually.', + impliedRisk: 'HIGH' + }, + { + code: 'SANCTIONS_HIT', + label: 'Sanctions / Banned Entity Match', + category: 'Screening', + description: 'Name screening returned a match against a designated or banned entity list.', + suggestedAction: 'Freeze pending disposition and report the match; do not release funds on a confirmed hit.', + impliedRisk: 'HIGH' + }, + { + code: 'CASH_INTENSIVE', + label: 'Cash Activity Inconsistent with Occupation', + category: 'Profile', + description: 'Cash turnover out of keeping with the occupation recorded at onboarding.', + suggestedAction: 'Confirm the occupation and expected turnover; correct the KYC record if it has changed.', + impliedRisk: 'MEDIUM' + } +]; + +const RULE_BY_CODE = new Map( + AML_RULE_CATALOGUE.map((r) => [ + r.code, + r + ]) +); + +/** + * Resolves a rule code to its catalogue entry. The engine may raise a rule the + * catalogue does not carry, so an unknown code degrades to a readable label + * built from the code itself rather than dropping the alert. + */ +export function amlRuleMeta(code: string, fallbackDescription?: string): AmlRuleMeta { + const known = RULE_BY_CODE.get(code); + if (known) { + return known; + } + return { + code, + label: code + .replace(/[_-]+/g, ' ') + .toLowerCase() + .replace(/\b\w/g, (c) => c.toUpperCase()), + category: 'Pattern', + description: fallbackDescription || 'Raised by the transaction monitoring engine.', + suggestedAction: 'Review the transaction and record the outcome against the customer’s risk rating.', + impliedRisk: 'MEDIUM' + }; +} + +// ── The report row ─────────────────────────────────────────────────────────── + +export type TxnChannel = + | 'CASH_DEPOSIT' + | 'CASH_WITHDRAWAL' + | 'NEFT' + | 'RTGS' + | 'IMPS' + | 'UPI' + | 'TRANSFER' + | 'CLEARING' + | 'INWARD_REMITTANCE' + | 'OUTWARD_REMITTANCE'; + +export const CHANNEL_LABEL: Record = { + CASH_DEPOSIT: 'Cash Deposit', + CASH_WITHDRAWAL: 'Cash Withdrawal', + NEFT: 'NEFT', + RTGS: 'RTGS', + IMPS: 'IMPS', + UPI: 'UPI', + TRANSFER: 'Internal Transfer', + CLEARING: 'Clearing', + INWARD_REMITTANCE: 'Inward Remittance', + OUTWARD_REMITTANCE: 'Outward Remittance' +}; + +export function channelLabel(channel: string): string { + return ( + CHANNEL_LABEL[channel as TxnChannel] || + channel + .replace(/[_-]+/g, ' ') + .toLowerCase() + .replace(/\b\w/g, (c) => c.toUpperCase()) + ); +} + +/** + * One transaction that contributed to an alert. + * + * A threshold rule fires on a single transaction; a pattern rule such as + * Structuring fires on a series, and the whole series is carried here so the + * reviewer can see exactly which entries the engine strung together. + */ +export interface RiskTxn { + txnId: string; + /** ISO yyyy-MM-dd — the value date of the entry. */ + date: string; + channel: string; + drCr: 'DR' | 'CR'; + amount: number; + /** Narration as it appears on the statement. */ + narration: string; + /** Other side of the entry — remitter, beneficiary, or the cash counter. */ + counterparty: string; + /** Balance after the entry, which is what makes pass-through behaviour visible. */ + balanceAfter: number; + /** True where this single entry is on its own above the CTR limit. */ + ctrReportable: boolean; +} + +/** One triggered alert — the unit the report is built from. */ +export interface RiskAlertRow { + alertId: string; + clientId: number; + customerName: string; + /** Blank where the source did not carry it (live alerts key on client, not account). */ + accountNo: string; + branchCode: string; + /** Risk rating on the customer's KYC record today, before this review. */ + currentRisk: RiskLevel | null; + ruleCode: string; + /** The engine's own wording, kept where it differs from the catalogue text. */ + ruleDescription: string; + channel: string; + amount: number; + currencyCode: string; + /** Count of transactions behind the alert — more than one for pattern rules. */ + txnCount: number; + /** ISO yyyy-MM-dd. */ + alertDate: string; + status: ReviewStatus; + reviewedBy: string; + remarks: string; + /** The entries the rule actually fired on. */ + txns: RiskTxn[]; + /** False for the sample fallback set, so the screen can say so. */ + live: boolean; +} + +/** CTR limit — a single cash entry at or above this is reportable on its own. */ +export const CTR_THRESHOLD = 1000000; + +/** + * Bands used to judge whether the period's activity is material enough to hold a + * customer at their current rating. + * + * A risk review runs both ways. A customer sitting at High who has only put + * small, ordinary transactions through should come back down to Medium or Low — + * leaving them at High wastes monitoring effort and, in a periodic-review + * regime, keeps calling them in for enhanced due diligence they no longer need. + */ +export const MATERIALITY = { + /** Largest single entry below this is small-value activity. */ + smallTxn: 100000, + /** Period total below this is immaterial for an individual customer. */ + smallTotal: 500000, + /** Period total at or above this holds the rating up on volume alone. */ + highTotal: 2500000 +}; + +/** + * Rules whose seriousness does not depend on the amount. A sanctions match or a + * deliberate structuring pattern is grave at any value, so activity being small + * is not a reason to bring the rating down. + */ +export const NON_MITIGABLE_RULES = new Set([ + 'SANCTIONS_HIT', + 'PEP', + 'STRUCTURING', + 'HIGH_RISK_COUNTRY' +]); + +/** Shape of one element of GET /aml/alerts. */ +export interface LiveAmlAlert { + id: number; + transactionType: string; + transactionId: number | null; + clientId: number; + clientDisplayName: string; + ruleCode: string; + ruleDescription: string; + amount: number; + currencyCode: string; + raisedOn: string; + status: string; + reviewedByUsername: string | null; + reviewedOn: string | null; + reviewNotes: string | null; + disposition: string | null; +} + +/** Trims a LocalDateTime such as 2026-07-14T11:04:22 down to its date part. */ +function isoDayOf(value: string | null | undefined): string { + return (value || '').slice(0, 10); +} + +/** + * Maps a live alert onto the report row. Account number and branch are not on + * the alert payload — the engine keys alerts to the client — so they stay blank + * and the template renders an em dash rather than inventing a value. + */ +export function mapLiveAlert(alert: LiveAmlAlert): RiskAlertRow { + const amount = Number(alert.amount) || 0; + const date = isoDayOf(alert.raisedOn); + const isCash = alert.transactionType?.includes('CASH'); + + return { + alertId: `A${alert.id}`, + clientId: alert.clientId, + customerName: alert.clientDisplayName, + accountNo: '', + branchCode: '', + currentRisk: null, + ruleCode: alert.ruleCode, + ruleDescription: alert.ruleDescription, + channel: alert.transactionType, + amount, + currencyCode: alert.currencyCode || 'INR', + txnCount: 1, + alertDate: date, + status: reviewStatusFromAlert(alert.status, alert.disposition), + reviewedBy: alert.reviewedByUsername || '', + remarks: alert.reviewNotes || '', + // The engine raises one alert per triggering transaction, so the live row + // carries exactly the entry it fired on. Narration, counterparty and the + // running balance are not on the alert payload and stay blank. + txns: [ + { + txnId: alert.transactionId != null ? String(alert.transactionId) : `A${alert.id}`, + date, + channel: alert.transactionType, + drCr: alert.transactionType?.includes('WITHDRAWAL') || alert.transactionType?.includes('OUTWARD') ? 'DR' : 'CR', + amount, + narration: alert.ruleDescription || '', + counterparty: '', + balanceAfter: 0, + ctrReportable: isCash && amount >= CTR_THRESHOLD + } + ], + live: true + }; +} + +// ── Deterministic sample set ───────────────────────────────────────────────── + +/** Fixed-seed linear congruential generator — identical figures on every load. */ +function lcg(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (Math.imul(s, 1664525) + 1013904223) >>> 0; + return s / 4294967296; + }; +} + +function pick(rand: () => number, list: T[]): T { + return list[Math.floor(rand() * list.length)]; +} + +function isoDate(d: Date): string { + const y = d.getUTCFullYear(); + const m = `${d.getUTCMonth() + 1}`.padStart(2, '0'); + const day = `${d.getUTCDate()}`.padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +const SAMPLE_NAMES = [ + 'Raj Kumar Sharma', + 'Meenakshi Iyer', + 'Abdul Rahman Shaikh', + 'Sunita Deshpande', + 'Harpreet Singh Bedi', + 'Farah Qureshi', + 'Vikram Rathod', + 'Lakshmi Narayanan', + 'Imran Sayyed', + 'Anjali Kulkarni', + 'Devendra Patil', + 'Rukhsana Begum', + 'Sanjay Gupta', + 'Priyanka Joshi', + 'Mohammed Arif Khan', + 'Nandini Rao', + 'Gopal Krishna Verma', + 'Shabana Ansari', + 'Ramesh Chandra Pandey', + 'Kavita Bhosale', + 'Yusuf Merchant', + 'Deepak Talwar', + 'Sneha Chatterjee', + 'Balwinder Kaur', + 'Aditya Mehrotra', + 'Zainab Lokhandwala', + 'Prakash Salunkhe', + 'Nusrat Jahan Sheikh', + 'Mahesh Waghmare', + 'Ritu Aggarwal', + 'Sadiq Hussain Momin', + 'Vaishali Gaikwad', + 'Tarun Bhatia', + 'Fatima Bano Ansari', + 'Suresh Jadhav', + 'Neelam Chauhan', + 'Iqbal Ahmed Pathan', + 'Girish Kamat', + 'Poonam Wadhwa', + 'Ashok Kumar Tiwari' +]; + +/** Occupation-ish trading names, used for the entity customers in the set. */ +const SAMPLE_ENTITIES = [ + 'Sai Enterprises', + 'Konkan Traders', + 'Nashik Agro Exports', + 'Vasai Metal Works', + 'Sunrise Jewellers', + 'Bhagyalakshmi Cotton Mills', + 'Om Sai Scrap & Metals', + 'Deccan Poultry Farms', + 'Mira Road Builders LLP', + 'Panvel Cold Storage Pvt Ltd', + 'Sharda Bullion House', + 'Western Cargo Movers' +]; + +/** Counterparty pools, so each rule's entries read like that rule. */ +const COUNTERPARTIES: Record = { + CASH: [ + 'Cash counter — Teller 03', + 'Cash counter — Teller 01', + 'Cash counter — Teller 07', + 'Cash recycler — Lobby' + ], + DOMESTIC: [ + 'RAMESH TRADING CO — HDFC0000123', + 'S K ENTERPRISES — ICIC0001847', + 'M/s VARDHMAN AGENCIES — SBIN0004512', + 'A R LOGISTICS — UTIB0000276', + 'GLOBAL IMPEX — KKBK0000958', + 'P N DISTRIBUTORS — BARB0PUNENA', + 'SHREE SALES CORP — PUNB0233800' + ], + RETAIL: [ + 'rahul.mehta@okaxis', + 'sk.traders@ybl', + 'anita9821@paytm', + 'jsons.retail@ibl', + 'quickmart.pos@okhdfcbank' + ], + CROSS_BORDER: [ + 'AL NAHDA GEN TRADING LLC — Dubai, AE', + 'PACIFIC RIM EXPORTS PTE — Singapore, SG', + 'ORIENT STAR TRADING — Hong Kong, HK', + 'DELTA COMMODITIES LTD — Lagos, NG', + 'HORIZON GULF FZE — Sharjah, AE' + ] +}; + +/** Statement narrations, keyed by rule so the entry explains the alert. */ +const NARRATIONS: Record = { + LARGE_CASH_TXN: [ + 'CASH DEP — self', + 'CASH DEP — sale proceeds', + 'CASH WDL — self', + 'CASH WDL — business payment' + ], + STRUCTURING: [ + 'CASH DEP — self', + 'CASH DEP — by third party', + 'CASH DEP — counter' + ], + HIGH_RISK_COUNTRY: [ + 'INW REM — advance against export', + 'OUT REM — import advance', + 'INW REM — consultancy fees' + ], + DORMANT_ACTIVATION: [ + 'NEFT IN — reactivation credit', + 'RTGS OUT — transfer to third party', + 'TRF — to linked account' + ], + UNUSUAL_PATTERN: [ + 'NEFT IN — business receipt', + 'IMPS IN — unknown remitter', + 'CASH DEP — self' + ], + VOLUME_SPIKE: [ + 'UPI CR — collection', + 'IMPS CR — collection', + 'NEFT IN — receipt' + ], + THIRD_PARTY: [ + 'IMPS CR — third party', + 'UPI CR — third party', + 'TRF CR — third party' + ], + RAPID_MOVEMENT: [ + 'RTGS IN — large credit', + 'RTGS OUT — same-day outflow', + 'NEFT OUT — onward transfer' + ], + CROSS_BORDER: [ + 'INW REM — family maintenance', + 'OUT REM — LRS remittance', + 'INW REM — export proceeds' + ], + PEP: [ + 'NEFT IN — receipt', + 'RTGS IN — receipt', + 'CASH DEP — self' + ], + SANCTIONS_HIT: [ + 'OUT REM — beneficiary screening hit', + 'RTGS OUT — counterparty screening hit' + ], + CASH_INTENSIVE: [ + 'CASH DEP — daily collection', + 'CASH WDL — wages', + 'CASH DEP — counter sales' + ] +}; + +function counterpartyFor(rand: () => number, channel: string): string { + if (channel.startsWith('CASH')) { + return pick(rand, COUNTERPARTIES['CASH']); + } + if (channel.includes('REMITTANCE')) { + return pick(rand, COUNTERPARTIES['CROSS_BORDER']); + } + if (channel === 'UPI' || channel === 'IMPS') { + return pick(rand, COUNTERPARTIES['RETAIL']); + } + return pick(rand, COUNTERPARTIES['DOMESTIC']); +} + +const RULE_CHANNELS: Record = { + LARGE_CASH_TXN: [ + 'CASH_DEPOSIT', + 'CASH_WITHDRAWAL' + ], + STRUCTURING: ['CASH_DEPOSIT'], + HIGH_RISK_COUNTRY: [ + 'INWARD_REMITTANCE', + 'OUTWARD_REMITTANCE' + ], + DORMANT_ACTIVATION: [ + 'NEFT', + 'RTGS', + 'TRANSFER' + ], + UNUSUAL_PATTERN: [ + 'NEFT', + 'IMPS', + 'CASH_DEPOSIT' + ], + VOLUME_SPIKE: [ + 'UPI', + 'IMPS', + 'NEFT' + ], + THIRD_PARTY: [ + 'IMPS', + 'UPI', + 'TRANSFER' + ], + RAPID_MOVEMENT: [ + 'RTGS', + 'NEFT' + ], + CROSS_BORDER: [ + 'INWARD_REMITTANCE', + 'OUTWARD_REMITTANCE' + ], + PEP: [ + 'NEFT', + 'RTGS', + 'CASH_DEPOSIT' + ], + SANCTIONS_HIT: [ + 'OUTWARD_REMITTANCE', + 'RTGS' + ], + CASH_INTENSIVE: [ + 'CASH_DEPOSIT', + 'CASH_WITHDRAWAL' + ] +}; + +const SAMPLE_REMARKS: Record = { + PENDING: [''], + IN_REVIEW: [ + 'Source of funds documents called for from the branch.', + 'Awaiting the customer’s explanation of the counterparty relationship.' + ], + RISK_RETAINED: [ + 'Sale proceeds of agricultural land; sale deed produced and verified. Rating unchanged.', + 'Regular business receipts consistent with the declared turnover. No further action.', + 'One-off receipt on maturity of a term deposit held with us. Satisfied.' + ], + RISK_UPGRADED: [ + 'Cash turnover ran to several times the declared income. Rating moved to High; review in six months.', + 'Counterparties could not be explained. Moved to High risk and placed under monthly monitoring.' + ], + KYC_UPDATE: [ + 'Occupation and income on record are stale. Re-KYC called for before further large credits.', + 'Address proof over three years old; customer asked to complete periodic updation.' + ], + ESCALATED_STR: [ + 'Deposits kept just under ₹10 lakh across five days. Referred to the Principal Officer.', + 'Account operated as a pass-through with no retained balance. Referred for STR consideration.' + ], + STR_FILED: [ + 'STR filed with FIU-IND within the seven working day limit. Account under continued watch.', + 'STR filed; relationship placed under senior management review.' + ] +}; + +const SAMPLE_REVIEWERS = [ + 'amlofficer', + 'compliance.mgr', + 'po.mumbai', + '' +]; + +/** How many entries the engine strung together for this rule. */ +function legCountFor(rand: () => number, ruleCode: string): number { + switch (ruleCode) { + case 'STRUCTURING': + return 4 + Math.floor(rand() * 9); + case 'VOLUME_SPIKE': + case 'THIRD_PARTY': + return 3 + Math.floor(rand() * 6); + case 'RAPID_MOVEMENT': + // A large credit and the outflow that empties it the same day. + return 2; + case 'CASH_INTENSIVE': + return 2 + Math.floor(rand() * 4); + default: + return 1; + } +} + +/** Amount for a single entry, in the band that makes the rule fire. */ +function legAmount(rand: () => number, ruleCode: string): number { + switch (ruleCode) { + case 'LARGE_CASH_TXN': + return Math.round((10 + rand() * 22) * 10000) * 10; + case 'STRUCTURING': + // Each leg deliberately parked just under the ₹10 lakh CTR limit. + return Math.round((8.6 + rand() * 1.3) * 10000) * 10; + case 'SANCTIONS_HIT': + case 'HIGH_RISK_COUNTRY': + case 'CROSS_BORDER': + return Math.round((4 + rand() * 40) * 10000) * 10; + case 'RAPID_MOVEMENT': + return Math.round((15 + rand() * 60) * 10000) * 10; + case 'VOLUME_SPIKE': + case 'THIRD_PARTY': + return Math.round((0.6 + rand() * 4) * 10000) * 10; + case 'CASH_INTENSIVE': + return Math.round((2 + rand() * 7) * 10000) * 10; + default: + return Math.round((1 + rand() * 18) * 10000) * 10; + } +} + +/** + * Builds the entries behind one alert, working backwards from the alert date. + * + * The alert amount is the sum of these legs rather than an independent figure, + * so the transaction view and the alert view reconcile to the rupee. + */ +function buildTxns(rand: () => number, ruleCode: string, channel: TxnChannel, alertDay: Date, seq: number): RiskTxn[] { + const legs = legCountFor(rand, ruleCode); + const narrations = NARRATIONS[ruleCode] || ['TRF — entry']; + // Pattern rules accumulate over a window; a single-entry rule fires on the day. + const windowDays = legs === 1 ? 0 : ruleCode === 'RAPID_MOVEMENT' ? 0 : 4 + Math.floor(rand() * 20); + + let balance = Math.round((2 + rand() * 25) * 100000); + const out: RiskTxn[] = []; + + for (let i = 0; i < legs; i++) { + // Legs run oldest-first up to the alert date. + const back = legs === 1 ? 0 : Math.round(((legs - 1 - i) / Math.max(legs - 1, 1)) * windowDays); + const date = isoDate(new Date(alertDay.getTime() - back * 86400000)); + + let amount = legAmount(rand, ruleCode); + let drCr: 'DR' | 'CR'; + let legChannel: TxnChannel = channel; + + if (ruleCode === 'RAPID_MOVEMENT') { + // Credit first, then the same money leaving less a token residue. + drCr = i === 0 ? 'CR' : 'DR'; + if (i === 1) { + amount = Math.round(out[0].amount * (0.93 + rand() * 0.06)); + legChannel = rand() < 0.5 ? 'RTGS' : 'NEFT'; + } + } else if (channel === 'CASH_WITHDRAWAL' || channel === 'OUTWARD_REMITTANCE') { + drCr = 'DR'; + } else if (ruleCode === 'CASH_INTENSIVE') { + drCr = rand() < 0.7 ? 'CR' : 'DR'; + legChannel = drCr === 'CR' ? 'CASH_DEPOSIT' : 'CASH_WITHDRAWAL'; + } else { + drCr = 'CR'; + } + + balance = drCr === 'CR' ? balance + amount : Math.max(balance - amount, 1200); + + out.push({ + txnId: `T${String(seq).padStart(5, '0')}${String(i + 1).padStart(2, '0')}`, + date, + channel: legChannel, + drCr, + amount, + narration: pick(rand, narrations), + counterparty: counterpartyFor(rand, legChannel), + balanceAfter: balance, + ctrReportable: legChannel.startsWith('CASH') && amount >= CTR_THRESHOLD + }); + } + + return out; +} + +// ── Hand-written showcase cases ────────────────────────────────────────────── + +/** A leg of a showcase case, before the running balance is worked out. */ +interface ShowcaseLeg { + date: string; + channel: TxnChannel; + drCr: 'DR' | 'CR'; + amount: number; + narration: string; + counterparty: string; +} + +interface ShowcaseCase { + clientId: number; + name: string; + branchCode: string; + accountNo: string; + currentRisk: RiskLevel; + ruleCode: string; + alertDate: string; + status: ReviewStatus; + reviewedBy: string; + remarks: string; + openingBalance: number; + legs: ShowcaseLeg[]; +} + +/** Repeats a leg n times on consecutive days — used for the structuring series. */ +function repeatLegs(n: number, startDate: string, leg: Omit): ShowcaseLeg[] { + const start = new Date(`${startDate}T00:00:00Z`); + return Array.from({ length: n }, (_, i) => ({ + ...leg, + date: isoDate(new Date(start.getTime() + i * 86400000)) + })); +} + +/** + * Cases written out by hand rather than generated, so the report opens on a set + * of recognisable, textbook examples — one per headline rule, with the figures + * and the reviewer's reasoning spelled out. These sit at the top of the sample + * set; the generated rows below fill out the volume. + */ +const SHOWCASE_CASES: ShowcaseCase[] = [ + { + clientId: 80001, + name: 'Raj Kumar Sharma', + branchCode: '0002', + accountNo: '000200041501', + currentRisk: 'MEDIUM', + ruleCode: 'LARGE_CASH_TXN', + alertDate: '2026-07-28', + status: 'PENDING', + reviewedBy: '', + remarks: '', + openingBalance: 284500, + legs: [ + { + date: '2026-07-28', + channel: 'CASH_DEPOSIT', + drCr: 'CR', + amount: 1500000, + narration: 'CASH DEP — self', + counterparty: 'Cash counter — Teller 03' + } + ] + }, + { + clientId: 80002, + name: 'Meenakshi Iyer', + branchCode: '0003', + accountNo: '000300041538', + currentRisk: 'LOW', + ruleCode: 'STRUCTURING', + alertDate: '2026-07-24', + status: 'ESCALATED_STR', + reviewedBy: 'amlofficer', + remarks: + 'Fifteen deposits of ₹90,000 on consecutive days — none individually reportable, ₹13,50,000 in aggregate. ' + + 'Customer could not explain the source. Referred to the Principal Officer as threshold avoidance.', + openingBalance: 61200, + legs: repeatLegs(15, '2026-07-10', { + channel: 'CASH_DEPOSIT', + drCr: 'CR', + amount: 90000, + narration: 'CASH DEP — self', + counterparty: 'Cash counter — Teller 01' + }) + }, + { + clientId: 80003, + name: 'Sunrise Jewellers', + branchCode: '0004', + accountNo: '000400041575', + currentRisk: 'MEDIUM', + ruleCode: 'HIGH_RISK_COUNTRY', + alertDate: '2026-07-19', + status: 'IN_REVIEW', + reviewedBy: 'compliance.mgr', + remarks: 'Import documents and the FEMA purpose code called for from the branch. Beneficiary being screened.', + openingBalance: 1840000, + legs: [ + { + date: '2026-07-19', + channel: 'INWARD_REMITTANCE', + drCr: 'CR', + amount: 4200000, + narration: 'INW REM — advance against export', + counterparty: 'AL NAHDA GEN TRADING LLC — Dubai, AE' + } + ] + }, + { + clientId: 80004, + name: 'Gopal Krishna Verma', + branchCode: '0005', + accountNo: '000500041612', + currentRisk: 'LOW', + ruleCode: 'DORMANT_ACTIVATION', + alertDate: '2026-07-16', + status: 'RISK_UPGRADED', + reviewedBy: 'amlofficer', + remarks: + 'Account dormant since Sep 2023, reactivated and ₹28,00,000 moved out over four days. ' + + 'Customer re-verified in person; rating moved Low → High and placed under monthly monitoring.', + openingBalance: 4820, + legs: [ + { + date: '2026-07-13', + channel: 'NEFT', + drCr: 'CR', + amount: 2800000, + narration: 'NEFT IN — reactivation credit', + counterparty: 'GLOBAL IMPEX — KKBK0000958' + }, + { + date: '2026-07-14', + channel: 'RTGS', + drCr: 'DR', + amount: 1200000, + narration: 'RTGS OUT — transfer to third party', + counterparty: 'A R LOGISTICS — UTIB0000276' + }, + { + date: '2026-07-15', + channel: 'RTGS', + drCr: 'DR', + amount: 900000, + narration: 'RTGS OUT — transfer to third party', + counterparty: 'S K ENTERPRISES — ICIC0001847' + }, + { + date: '2026-07-16', + channel: 'RTGS', + drCr: 'DR', + amount: 690000, + narration: 'RTGS OUT — transfer to third party', + counterparty: 'SHREE SALES CORP — PUNB0233800' + } + ] + }, + { + clientId: 80005, + name: 'Vikram Rathod', + branchCode: '0002', + accountNo: '000200041649', + currentRisk: 'LOW', + ruleCode: 'UNUSUAL_PATTERN', + alertDate: '2026-07-22', + status: 'KYC_UPDATE', + reviewedBy: 'compliance.mgr', + remarks: + 'Declared income at onboarding ₹4,80,000 p.a.; credits of ₹67,00,000 in the quarter. ' + + 'Occupation on record is stale — re-KYC called for before any further large credit is released.', + openingBalance: 132000, + legs: [ + { + date: '2026-07-06', + channel: 'NEFT', + drCr: 'CR', + amount: 2450000, + narration: 'NEFT IN — business receipt', + counterparty: 'RAMESH TRADING CO — HDFC0000123' + }, + { + date: '2026-07-14', + channel: 'NEFT', + drCr: 'CR', + amount: 1980000, + narration: 'NEFT IN — business receipt', + counterparty: 'M/s VARDHMAN AGENCIES — SBIN0004512' + }, + { + date: '2026-07-22', + channel: 'CASH_DEPOSIT', + drCr: 'CR', + amount: 2270000, + narration: 'CASH DEP — self', + counterparty: 'Cash counter — Teller 07' + } + ] + }, + { + clientId: 80006, + name: 'Om Sai Scrap & Metals', + branchCode: '0006', + accountNo: '000600041686', + currentRisk: 'MEDIUM', + ruleCode: 'RAPID_MOVEMENT', + alertDate: '2026-07-09', + status: 'STR_FILED', + reviewedBy: 'po.mumbai', + remarks: + '₹55,00,000 received and ₹53,80,000 moved on the same day, leaving ₹1,20,000. No trading activity to support it. ' + + 'STR filed with FIU-IND on 15-Jul-2026 within the seven working day limit.', + openingBalance: 98000, + legs: [ + { + date: '2026-07-09', + channel: 'RTGS', + drCr: 'CR', + amount: 5500000, + narration: 'RTGS IN — large credit', + counterparty: 'ORIENT STAR TRADING — Hong Kong, HK' + }, + { + date: '2026-07-09', + channel: 'RTGS', + drCr: 'DR', + amount: 5380000, + narration: 'RTGS OUT — same-day outflow', + counterparty: 'P N DISTRIBUTORS — BARB0PUNENA' + } + ] + }, + { + clientId: 80007, + name: 'Anjali Kulkarni', + branchCode: '0003', + accountNo: '000300041723', + currentRisk: 'LOW', + ruleCode: 'THIRD_PARTY', + alertDate: '2026-07-30', + status: 'PENDING', + reviewedBy: '', + remarks: '', + openingBalance: 47300, + legs: [ + { + date: '2026-07-25', + channel: 'IMPS', + drCr: 'CR', + amount: 148000, + narration: 'IMPS CR — third party', + counterparty: 'rahul.mehta@okaxis' + }, + { + date: '2026-07-26', + channel: 'UPI', + drCr: 'CR', + amount: 96500, + narration: 'UPI CR — third party', + counterparty: 'sk.traders@ybl' + }, + { + date: '2026-07-27', + channel: 'IMPS', + drCr: 'CR', + amount: 172000, + narration: 'IMPS CR — third party', + counterparty: 'anita9821@paytm' + }, + { + date: '2026-07-28', + channel: 'UPI', + drCr: 'CR', + amount: 88000, + narration: 'UPI CR — third party', + counterparty: 'jsons.retail@ibl' + }, + { + date: '2026-07-29', + channel: 'IMPS', + drCr: 'CR', + amount: 205000, + narration: 'IMPS CR — third party', + counterparty: 'quickmart.pos@okhdfcbank' + }, + { + date: '2026-07-30', + channel: 'UPI', + drCr: 'CR', + amount: 134500, + narration: 'UPI CR — third party', + counterparty: 'sk.traders@ybl' + } + ] + }, + { + clientId: 80008, + name: 'Deepak Talwar', + branchCode: '0004', + accountNo: '000400041760', + currentRisk: 'HIGH', + ruleCode: 'SANCTIONS_HIT', + alertDate: '2026-07-31', + status: 'ESCALATED_STR', + reviewedBy: 'po.mumbai', + remarks: + 'Beneficiary returned a match on the designated-entity list. Remittance held and not released. ' + + 'Match reported and the relationship referred for senior management review.', + openingBalance: 3120000, + legs: [ + { + date: '2026-07-31', + channel: 'OUTWARD_REMITTANCE', + drCr: 'DR', + amount: 1800000, + narration: 'OUT REM — beneficiary screening hit', + counterparty: 'DELTA COMMODITIES LTD — Lagos, NG' + } + ] + }, + { + clientId: 80009, + name: 'Nashik Agro Exports', + branchCode: '0005', + accountNo: '000500041797', + currentRisk: 'MEDIUM', + ruleCode: 'CASH_INTENSIVE', + alertDate: '2026-07-27', + status: 'RISK_RETAINED', + reviewedBy: 'amlofficer', + remarks: + 'Cash collections match the mandi receipts and the declared seasonal turnover for a produce exporter. ' + + 'Documents verified at the branch; rating unchanged, review again after the next harvest cycle.', + openingBalance: 760000, + legs: [ + { + date: '2026-07-21', + channel: 'CASH_DEPOSIT', + drCr: 'CR', + amount: 640000, + narration: 'CASH DEP — daily collection', + counterparty: 'Cash counter — Teller 03' + }, + { + date: '2026-07-23', + channel: 'CASH_DEPOSIT', + drCr: 'CR', + amount: 585000, + narration: 'CASH DEP — daily collection', + counterparty: 'Cash recycler — Lobby' + }, + { + date: '2026-07-25', + channel: 'CASH_WITHDRAWAL', + drCr: 'DR', + amount: 410000, + narration: 'CASH WDL — wages', + counterparty: 'Cash counter — Teller 01' + }, + { + date: '2026-07-27', + channel: 'CASH_DEPOSIT', + drCr: 'CR', + amount: 712000, + narration: 'CASH DEP — counter sales', + counterparty: 'Cash counter — Teller 07' + } + ] + }, + { + // Rated High, but the period's activity is trivial — the review should bring + // the rating down rather than leave it where an old alert put it. + clientId: 80011, + name: 'Balwinder Kaur', + branchCode: '0002', + accountNo: '000200041871', + currentRisk: 'HIGH', + ruleCode: 'THIRD_PARTY', + alertDate: '2026-07-26', + status: 'RISK_RETAINED', + reviewedBy: 'amlofficer', + remarks: + 'Rated High in 2024 after a one-off remittance since fully explained. Since then the account has only ' + + 'carried small UPI receipts from her tenants. Nothing in the period supports a High rating.', + openingBalance: 41800, + legs: [ + { + date: '2026-07-08', + channel: 'UPI', + drCr: 'CR', + amount: 18500, + narration: 'UPI CR — third party', + counterparty: 'rahul.mehta@okaxis' + }, + { + date: '2026-07-14', + channel: 'UPI', + drCr: 'CR', + amount: 22000, + narration: 'UPI CR — third party', + counterparty: 'anita9821@paytm' + }, + { + date: '2026-07-20', + channel: 'IMPS', + drCr: 'CR', + amount: 27500, + narration: 'IMPS CR — third party', + counterparty: 'jsons.retail@ibl' + }, + { + date: '2026-07-26', + channel: 'UPI', + drCr: 'CR', + amount: 19800, + narration: 'UPI CR — third party', + counterparty: 'sk.traders@ybl' + } + ] + }, + { + // Rated High, activity moderate rather than trivial — comes down one step. + clientId: 80012, + name: 'Sanjay Gupta', + branchCode: '0003', + accountNo: '000300041908', + currentRisk: 'HIGH', + ruleCode: 'VOLUME_SPIKE', + alertDate: '2026-07-23', + status: 'IN_REVIEW', + reviewedBy: 'compliance.mgr', + remarks: + 'Rated High during a business expansion two years ago. Turnover has settled to a steady ₹6–7 lakh a month, ' + + 'in line with the declared trade. Enhanced monitoring no longer proportionate.', + openingBalance: 218000, + legs: [ + { + date: '2026-07-09', + channel: 'NEFT', + drCr: 'CR', + amount: 245000, + narration: 'NEFT IN — receipt', + counterparty: 'RAMESH TRADING CO — HDFC0000123' + }, + { + date: '2026-07-16', + channel: 'NEFT', + drCr: 'CR', + amount: 198000, + narration: 'NEFT IN — receipt', + counterparty: 'SHREE SALES CORP — PUNB0233800' + }, + { + date: '2026-07-23', + channel: 'IMPS', + drCr: 'CR', + amount: 227000, + narration: 'IMPS CR — collection', + counterparty: 'quickmart.pos@okhdfcbank' + } + ] + }, + { + clientId: 80010, + name: 'Farah Qureshi', + branchCode: '0006', + accountNo: '000600041834', + currentRisk: 'LOW', + ruleCode: 'VOLUME_SPIKE', + alertDate: '2026-07-29', + status: 'IN_REVIEW', + reviewedBy: 'amlofficer', + remarks: 'Monthly volume ran to eleven times the twelve-month average. Branch asked for the business reason.', + openingBalance: 88400, + legs: [ + { + date: '2026-07-18', + channel: 'UPI', + drCr: 'CR', + amount: 264000, + narration: 'UPI CR — collection', + counterparty: 'quickmart.pos@okhdfcbank' + }, + { + date: '2026-07-22', + channel: 'IMPS', + drCr: 'CR', + amount: 318000, + narration: 'IMPS CR — collection', + counterparty: 'jsons.retail@ibl' + }, + { + date: '2026-07-26', + channel: 'NEFT', + drCr: 'CR', + amount: 407000, + narration: 'NEFT IN — receipt', + counterparty: 'SHREE SALES CORP — PUNB0233800' + }, + { + date: '2026-07-29', + channel: 'UPI', + drCr: 'CR', + amount: 292000, + narration: 'UPI CR — collection', + counterparty: 'sk.traders@ybl' + } + ] + } +]; + +function buildShowcaseAlerts(): RiskAlertRow[] { + return SHOWCASE_CASES.map((c, i) => { + let balance = c.openingBalance; + const txns: RiskTxn[] = c.legs.map((leg, j) => { + balance = leg.drCr === 'CR' ? balance + leg.amount : Math.max(balance - leg.amount, 0); + return { + txnId: `T9${String(i + 1).padStart(2, '0')}${String(j + 1).padStart(2, '0')}`, + date: leg.date, + channel: leg.channel, + drCr: leg.drCr, + amount: leg.amount, + narration: leg.narration, + counterparty: leg.counterparty, + balanceAfter: balance, + ctrReportable: leg.channel.startsWith('CASH') && leg.amount >= CTR_THRESHOLD + }; + }); + + const rule = amlRuleMeta(c.ruleCode); + return { + alertId: `RR9${String(i + 1).padStart(4, '0')}`, + clientId: c.clientId, + customerName: c.name, + accountNo: c.accountNo, + branchCode: c.branchCode, + currentRisk: c.currentRisk, + ruleCode: c.ruleCode, + ruleDescription: rule.description, + channel: c.legs[0].channel, + amount: txns.reduce((s, t) => s + t.amount, 0), + currencyCode: 'INR', + txnCount: txns.length, + alertDate: c.alertDate, + status: c.status, + reviewedBy: c.reviewedBy, + remarks: c.remarks, + txns, + live: false + }; + }); +} + +function buildSampleAlerts(): RiskAlertRow[] { + const rand = lcg(20260801); + const branches = GL_BRANCHES.filter((b) => b.code !== 'HO'); + const start = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`); + const end = new Date(`${GL_PERIOD.fyEnd}T00:00:00Z`); + const spanDays = Math.round((end.getTime() - start.getTime()) / 86400000); + + const customers = [ + ...SAMPLE_NAMES, + ...SAMPLE_ENTITIES + ].map((name, i) => ({ + clientId: 90001 + i, + name, + branchCode: branches[i % branches.length].code, + accountNo: `${branches[i % branches.length].code}${String(41500 + i * 37).padStart(8, '0')}`, + // Rating on the KYC record today — most customers sit at Low or Medium + // before the review; that is the point of running the report. + currentRisk: (rand() < 0.55 ? 'LOW' : rand() < 0.78 ? 'MEDIUM' : 'HIGH') as RiskLevel + })); + + // The hand-written cases lead; the generated ones fill out the volume behind them. + const rows: RiskAlertRow[] = buildShowcaseAlerts(); + let seq = 1; + + for (const cust of customers) { + const alertCount = 1 + Math.floor(rand() * 3); + for (let n = 0; n < alertCount; n++) { + const rule = pick(rand, AML_RULE_CATALOGUE); + const channel = pick(rand, RULE_CHANNELS[rule.code] ?? (['TRANSFER'] as TxnChannel[])); + const day = new Date(start.getTime() + Math.floor(rand() * spanDays) * 86400000); + + // Older alerts have mostly been dealt with; recent ones are still open. + const ageDays = Math.round((end.getTime() - day.getTime()) / 86400000); + let status: ReviewStatus; + if (ageDays < 12) { + status = rand() < 0.6 ? 'PENDING' : 'IN_REVIEW'; + } else if (rule.impliedRisk === 'HIGH') { + const r = rand(); + status = r < 0.28 ? 'ESCALATED_STR' : r < 0.46 ? 'STR_FILED' : r < 0.72 ? 'RISK_UPGRADED' : 'RISK_RETAINED'; + } else { + const r = rand(); + status = r < 0.42 ? 'RISK_RETAINED' : r < 0.64 ? 'KYC_UPDATE' : r < 0.82 ? 'RISK_UPGRADED' : 'IN_REVIEW'; + } + + const txns = buildTxns(rand, rule.code, channel, day, seq); + + rows.push({ + alertId: `RR${String(seq++).padStart(5, '0')}`, + clientId: cust.clientId, + customerName: cust.name, + accountNo: cust.accountNo, + branchCode: cust.branchCode, + currentRisk: cust.currentRisk, + ruleCode: rule.code, + ruleDescription: rule.description, + channel, + // Derived from the entries so the two views reconcile. + amount: txns.reduce((s, t) => s + t.amount, 0), + currencyCode: 'INR', + txnCount: txns.length, + alertDate: isoDate(day), + status, + reviewedBy: REVIEW_STATUS_META[status].open ? '' : pick(rand, SAMPLE_REVIEWERS) || 'amlofficer', + remarks: pick(rand, SAMPLE_REMARKS[status]), + txns, + live: false + }); + } + } + + return rows.sort((a, b) => b.alertDate.localeCompare(a.alertDate)); +} + +let sampleCache: RiskAlertRow[] | null = null; + +/** The fallback set, built once and reused. */ +export function sampleRiskAlerts(): RiskAlertRow[] { + if (!sampleCache) { + sampleCache = buildSampleAlerts(); + } + return sampleCache; +} diff --git a/src/app/risk-review/risk-review-report.component.html b/src/app/risk-review/risk-review-report.component.html new file mode 100644 index 000000000..159139988 --- /dev/null +++ b/src/app/risk-review/risk-review-report.component.html @@ -0,0 +1,527 @@ + + +
+ +
+
+ +
+

Risk Review Report

+
+ Customers whose transactions tripped an AML rule and whose risk profile therefore needs a look. For each case + the reviewer decides one of four things — keep the rating, move it, call for a KYC update, or refer it to the + Principal Officer for an STR. +
+
+
+
+ +
+
+ + @if (sampleCount) { +
+ + + {{ sampleCount }} demonstration rows are included alongside + {{ hasLive ? 'the live alerts' : 'the live feed, which has raised nothing' }}. Sample rows carry a + Sample tag in the table and must not be acted on as compliance data. + + +
+ } + + +
+
+
+ @for (m of periodModes; track m.key) { + + } +
+ + @switch (periodMode) { + @case ('day') { + + } + @case ('week') { + + Week: {{ rangeLabel }} + } + @case ('month') { + + } + @default { + + + } + } +
+ +
+ + + + + + + + + + + + + + + +
+ + @if (!canSelectBranch) { +
+ + Showing {{ branchLabel }} only. Head Office logins can select any branch. +
+ } +
+ + @if (loading) { +
Loading AML alerts…
+ } @else { + +
+
+
{{ summary.customers }}
+
Customers for review
+
+ {{ summary.byRisk.LOW }} Low · {{ summary.byRisk.MEDIUM }} Medium · {{ summary.byRisk.HIGH }} High +
+
+
+
{{ summary.alerts }}
+
AML alerts triggered
+
on {{ summary.txns }} transactions · {{ summary.openAlerts }} awaiting a decision
+
+
+
{{ summary.upgradeCandidates + summary.downgradeCandidates }}
+
Ratings to be moved
+
+ {{ summary.upgradeCandidates }} upward · {{ summary.downgradeCandidates }} downward +
+
+
+
{{ summary.escalatedCustomers }}
+
Escalated to STR
+
₹{{ summary.totalAmount | number: '1.0-0' }} under review
+
+
+ + + @if (summary.byRule.length) { +
+ Rules triggered + @for (r of summary.byRule; track r.code) { + + } +
+ } + + +
+ + + + + + {{ + view === 'customer' + ? customerRows.length + ' customers' + : view === 'alert' + ? alertRows.length + ' alerts' + : txnRows.length + ' transactions' + }} + +
+ + + @if (view === 'customer') { +
+ + + + + + + + + + + + + + + + + + @for (c of customerRows; track c.clientId) { + + + + + + + + + + + + + + + @if (expanded === c.clientId) { + + + + } + } @empty { + + + + } + +
Customer IDCustomer NameAccount NumberBranchCurrent RiskAlertsAML Rules TriggeredAmount under reviewLast AlertPosition
+ + {{ c.clientId }} + {{ c.customerName }} + @if (c.openCount > 0) { + {{ c.openCount }} open + } + {{ c.accountNo || '—' }}{{ c.branchCode || '—' }} + {{ riskText(c.currentRisk) }} + {{ c.alertCount }} + @for (label of c.ruleLabels; track label) { + {{ label }} + } + {{ c.totalAmount | number: '1.0-0' }}{{ c.lastAlertDate }} + {{ verdictText(c) }} + @if (c.directionReason) { +
{{ c.directionReason }}
+ } +
+ + @for (a of c.alerts; track a.alertId) { +
+
+ {{ a.rule.label }} + {{ a.rule.category }} + {{ a.alertId }} · raised {{ a.alertDate }} + + ₹{{ a.amount | number: '1.0-0' }} + {{ statusMeta[a.status].label }} +
+
+ + Why it triggered: {{ a.rule.description }} — {{ txnDetail(a) }} +
+ + + + + + + + + + + + + + + + + @for (t of a.txns; track t.txnId) { + + + + + + + + + + + + } + +
Txn DateTxn IDChannelDr/CrAmountBalance AfterNarrationCounterpartyAML Rule Triggered
{{ t.date }}{{ t.txnId }} + {{ channelLabel(t.channel) }} + {{ t.drCr }} + {{ t.amount | number: '1.0-0' }} + @if (t.ctrReportable) { + CTR + } + {{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}{{ t.narration || '—' }}{{ t.counterparty || '—' }} + {{ a.rule.label }} + @if (a.txns.length > 1) { + leg {{ $index + 1 }} of {{ a.txns.length }} + } +
+ +
+
+ {{ a.rule.suggestedAction }} +
+
+ Reviewer: {{ a.reviewedBy || 'not yet assigned' }} — + {{ a.remarks || 'no remarks recorded' }} +
+
+
+ } +
+ No customer tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}. +
+
+ } + + + @if (view === 'alert') { +
+ + + + + + + + + + + + + + + + + + + @for (a of alertRows; track a.alertId) { + + + + + + + + + + + + + + + } @empty { + + + + } + +
Alert IDAlert DateCustomer IDCustomer NameAccount NumberBranchCurrent RiskTransaction DetailsAML Rule TriggeredAmountReview StatusReviewer Remarks
{{ a.alertId }}{{ a.alertDate }}{{ a.clientId }} + {{ a.customerName }} + @if (!a.live) { + Sample + } + {{ a.accountNo || '—' }}{{ a.branchCode || '—' }} + {{ riskText(a.currentRisk) }} + {{ txnDetail(a) }} + {{ a.rule.label }} + {{ a.rule.category }} +
{{ a.rule.description }}
+
{{ a.amount | number: '1.0-0' }} + {{ statusMeta[a.status].label }} + @if (a.reviewedBy) { +
by {{ a.reviewedBy }}
+ } +
+ {{ a.remarks || '—' }} +
{{ a.rule.suggestedAction }}
+
+ No AML rule was triggered for {{ rangeLabel }} at {{ branchLabel }}. +
+
+ } + + + @if (view === 'txn') { +
+ + + + + + + + + + + + + + + + + + + + + + + @for (t of txnRows; track t.txnId) { + + + + + + + + + + + + + + + + + + + } @empty { + + + + } + +
Txn DateTxn IDCustomer IDCustomer NameAccount NumberBranchCurrent RiskChannelDr/CrAmountBalance AfterNarrationCounterpartyAML Rule TriggeredAlertReview Status
{{ t.date }}{{ t.txnId }}{{ t.clientId }} + {{ t.customerName }} + {{ t.accountNo || '—' }}{{ t.branchCode || '—' }} + {{ riskText(t.currentRisk) }} + + {{ channelLabel(t.channel) }} + {{ t.drCr }} + {{ t.amount | number: '1.0-0' }} + @if (t.ctrReportable) { + CTR + } + {{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}{{ t.narration || '—' }}{{ t.counterparty || '—' }} + {{ t.rule.label }} + {{ t.rule.category }} + @if (t.legCount > 1) { + leg {{ t.legIndex }} of {{ t.legCount }} + } +
{{ t.rule.description }}
+
{{ t.alertId }} + {{ statusMeta[t.status].label }} +
+ No transaction tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}. +
+
+ } + +

+ Pending review awaiting a reviewer · + Risk retained explained, rating unchanged · + Risk upgraded / KYC update profile changed · + Escalated / STR filed with the Principal Officer +

+ } + +
{{ toastMsg }}
+
diff --git a/src/app/risk-review/risk-review-report.component.scss b/src/app/risk-review/risk-review-report.component.scss new file mode 100644 index 000000000..aacb799ff --- /dev/null +++ b/src/app/risk-review/risk-review-report.component.scss @@ -0,0 +1,820 @@ +/** + * 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/. + */ + +:host { + --r-navy: #1a237e; + --r-blue: #1d4ed8; + --r-blue-l: #eff6ff; + --r-green: #15803d; + --r-green-l: #dcfce7; + --r-amber: #b45309; + --r-amber-l: #fef3c7; + --r-red: #c0392b; + --r-red-l: #fdecea; + --r-violet: #6d28d9; + --r-violet-l: #f3e8ff; + --r-g50: #f8fafc; + --r-g100: #f1f5f9; + --r-g200: #e2e8f0; + --r-g300: #cbd5e1; + --r-g500: #64748b; + --r-g700: #334155; + --r-g900: #0f172a; + + display: block; + font-family: 'DM Sans', system-ui, sans-serif; + background: var(--r-g50); + color: var(--r-g900); + font-size: 14px; + line-height: 1.5; +} + +.rr-root { + padding: 18px 22px; + max-width: 1800px; + margin: 0 auto; +} + +/* HEADER */ +.rr-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 14px; +} + +.rr-head-left { + display: flex; + align-items: flex-start; + gap: 14px; +} + +.rr-head-ic { + font-size: 26px; + color: var(--r-navy); + margin-top: 3px; +} + +.rr-head-t { + margin: 0; + font-size: 18px; + font-weight: 700; +} + +.rr-head-s { + font-size: 12px; + color: var(--r-g500); + max-width: 900px; +} + +/* BUTTONS */ +.rr-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 15px; + border: 1px solid var(--r-g300); + background: #fff; + border-radius: 8px; + font-size: 12.5px; + font-weight: 600; + color: var(--r-g700); + cursor: pointer; + font-family: inherit; + transition: all 0.15s; + + &:hover:not(:disabled) { + background: var(--r-g50); + border-color: var(--r-g500); + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } + + &--primary { + background: var(--r-green); + border-color: var(--r-green); + color: #fff; + + &:hover:not(:disabled) { + background: #12692f; + border-color: #12692f; + color: #fff; + } + } + + &--slim { + padding: 5px 11px; + font-size: 11.5px; + white-space: nowrap; + } +} + +.rr-head-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +/* BANNER */ +.rr-banner { + display: flex; + align-items: center; + gap: 11px; + border-radius: 10px; + padding: 10px 14px; + font-size: 12.5px; + margin-bottom: 14px; + + span { + flex: 1; + } + + &--sample { + background: var(--r-amber-l); + border: 1px solid #fcd34d; + color: #78350f; + } +} + +/* FILTERS */ +.rr-filters { + background: #fff; + border: 1px solid var(--r-g200); + border-radius: 11px; + padding: 12px 16px; + margin-bottom: 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.rr-filter-row { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.rr-modes { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.rr-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border: 1px solid var(--r-g300); + background: #fff; + border-radius: 999px; + font-size: 12.5px; + font-weight: 600; + color: var(--r-g700); + cursor: pointer; + font-family: inherit; + + &:hover { + border-color: var(--r-navy); + } + + &.active { + background: var(--r-navy); + border-color: var(--r-navy); + color: #fff; + } +} + +.rr-field { + display: flex; + flex-direction: column; + gap: 3px; + + span { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--r-g500); + } + + input, + select { + padding: 7px 10px; + border: 1px solid var(--r-g300); + border-radius: 7px; + font-size: 13px; + font-family: inherit; + background: #fff; + color: var(--r-g900); + + &:focus { + outline: none; + border-color: var(--r-navy); + } + + &:disabled { + background: var(--r-g100); + color: var(--r-g500); + } + } + + &--grow { + flex: 1; + min-width: 260px; + + input { + width: 100%; + } + } +} + +.rr-resolved { + font-size: 12px; + color: var(--r-g500); + padding-bottom: 8px; +} + +.rr-check { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + font-weight: 600; + color: var(--r-g700); + padding-bottom: 8px; + cursor: pointer; +} + +.rr-scope-note { + display: flex; + align-items: center; + gap: 7px; + font-size: 12px; + color: var(--r-amber); + background: var(--r-amber-l); + border: 1px solid #fcd34d; + border-radius: 7px; + padding: 6px 11px; +} + +/* LOADING */ +.rr-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 9px; + padding: 60px; + color: var(--r-g500); + font-size: 13px; + background: #fff; + border: 1px solid var(--r-g200); + border-radius: 11px; +} + +/* KPIs */ +.rr-kpis { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 14px; +} + +.rr-kpi { + background: #fff; + border: 1px solid var(--r-g200); + border-left: 4px solid var(--r-navy); + border-radius: 11px; + padding: 12px 16px; + + &--blue { + border-left-color: var(--r-blue); + } + + &--amber { + border-left-color: var(--r-amber); + } + + &--red { + border-left-color: var(--r-red); + } + + &--dim { + border-left-color: var(--r-g300); + } +} + +.rr-kpi-n { + font-size: 22px; + font-weight: 700; + font-family: 'DM Mono', monospace; +} + +.rr-kpi-l { + font-size: 12px; + font-weight: 600; + color: var(--r-g700); +} + +.rr-kpi-sub { + font-size: 11px; + color: var(--r-g500); +} + +/* RULE MIX */ +.rr-mix { + display: flex; + align-items: center; + gap: 7px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.rr-mix-t { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--r-g500); + margin-right: 2px; +} + +.rr-mix-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 11px; + border: 1px solid var(--r-g200); + background: #fff; + border-radius: 7px; + font-size: 11.5px; + font-weight: 600; + color: var(--r-g700); + cursor: pointer; + font-family: inherit; + + strong { + font-family: 'DM Mono', monospace; + color: var(--r-navy); + } + + &:hover { + border-color: var(--r-navy); + } + + &.active { + background: var(--r-navy); + border-color: var(--r-navy); + color: #fff; + + strong { + color: #fff; + } + } +} + +/* VIEW SWITCH */ +.rr-views { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +.rr-spacer { + flex: 1; +} + +.rr-count { + font-size: 12px; + color: var(--r-g500); + font-weight: 600; +} + +/* TABLE */ +.rr-table-wrap { + background: #fff; + border: 1px solid var(--r-g200); + border-radius: 11px; + overflow-x: auto; +} + +.rr-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th { + text-align: left; + padding: 9px 10px; + background: var(--r-g100); + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--r-g700); + white-space: nowrap; + border-bottom: 1px solid var(--r-g200); + } + + td { + padding: 8px 10px; + border-bottom: 1px solid var(--r-g100); + vertical-align: top; + } + + .num { + text-align: right; + font-family: 'DM Mono', monospace; + } + + .strong { + font-weight: 700; + } + + tbody tr:hover:not(.rr-detail) { + background: var(--r-g50); + } +} + +.rr-cust { + cursor: pointer; + + &.open { + background: var(--r-blue-l); + } +} + +.rr-row--escalated { + background: var(--r-red-l); +} + +.rr-detail { + background: #fcfcfe; + + td { + padding: 12px 16px 16px; + } +} + +.rr-subtable { + width: 100%; + border-collapse: collapse; + font-size: 12px; + background: #fff; + border: 1px solid var(--r-g200); + border-radius: 8px; + overflow: hidden; + + th { + text-align: left; + padding: 7px 10px; + background: var(--r-g50); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--r-g500); + white-space: nowrap; + } + + td { + padding: 7px 10px; + border-top: 1px solid var(--r-g100); + vertical-align: top; + } + + .num { + text-align: right; + font-family: 'DM Mono', monospace; + } + + .strong { + font-weight: 700; + } +} + +/* One alert inside an expanded customer: rule, why, entries, outcome. */ +.rr-case { + background: #fff; + border: 1px solid var(--r-g200); + border-radius: 9px; + padding: 10px 12px; + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } +} + +.rr-case-head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 6px; +} + +.rr-case-id { + font-size: 11px; + color: var(--r-g500); +} + +.rr-case-amt { + font-size: 13px; + font-weight: 700; +} + +.rr-case-why { + display: flex; + align-items: baseline; + gap: 7px; + font-size: 11.5px; + color: #78350f; + background: var(--r-amber-l); + border-radius: 6px; + padding: 5px 10px; + margin-bottom: 8px; +} + +.rr-case-foot { + display: flex; + align-items: baseline; + gap: 18px; + flex-wrap: wrap; + margin-top: 7px; + padding-top: 7px; + border-top: 1px dashed var(--r-g200); +} + +.rr-case-remark { + font-size: 11.5px; + color: var(--r-g700); +} + +/* Channel of a single entry */ +.rr-chan { + display: inline-block; + padding: 1px 7px; + border-radius: 5px; + font-size: 10.5px; + font-weight: 700; + background: var(--r-g100); + color: var(--r-g700); + white-space: nowrap; +} + +/* Position of an entry within a multi-leg pattern alert */ +.rr-leg { + display: inline-block; + margin-left: 5px; + padding: 1px 7px; + border-radius: 5px; + font-size: 10px; + font-weight: 700; + background: var(--r-blue-l); + color: var(--r-blue); + white-space: nowrap; +} + +.rr-leg--ctr { + background: var(--r-red-l); +} + +.cr { + color: var(--r-green); + font-weight: 700; +} + +.dr { + color: var(--r-red); + font-weight: 700; +} + +/* Rule cell */ +.rr-rules-cell { + min-width: 230px; + max-width: 340px; +} + +.rr-rule { + display: inline-block; + margin: 0 5px 3px 0; + padding: 1px 8px; + border-radius: 999px; + font-size: 10.5px; + font-weight: 700; + background: var(--r-violet-l); + color: var(--r-violet); +} + +.rr-rule-cat { + display: inline-block; + padding: 1px 7px; + border-radius: 5px; + font-size: 10px; + font-weight: 700; + background: var(--r-g100); + color: var(--r-g700); +} + +.rr-rule-desc { + font-size: 11px; + color: var(--r-g500); + margin-top: 2px; +} + +.rr-suggest { + font-size: 11px; + color: var(--r-blue); + margin-top: 3px; + display: flex; + align-items: baseline; + gap: 5px; +} + +.rr-by { + font-size: 10.5px; + color: var(--r-g500); + margin-top: 2px; +} + +/* Risk rating badge */ +.rr-risk { + display: inline-block; + padding: 1px 9px; + border-radius: 999px; + font-size: 10.5px; + font-weight: 700; + white-space: nowrap; + + &--low { + background: var(--r-green-l); + color: var(--r-green); + } + + &--medium { + background: var(--r-amber-l); + color: var(--r-amber); + } + + &--high { + background: var(--r-red-l); + color: var(--r-red); + } + + &--none { + background: var(--r-g100); + color: var(--r-g500); + } +} + +/* Review status badge */ +.rr-status { + display: inline-block; + padding: 1px 9px; + border-radius: 999px; + font-size: 10.5px; + font-weight: 700; + white-space: nowrap; + + &--open { + background: var(--r-g100); + color: var(--r-g700); + } + + &--progress { + background: var(--r-blue-l); + color: var(--r-blue); + } + + &--ok { + background: var(--r-green-l); + color: var(--r-green); + } + + &--warn { + background: var(--r-amber-l); + color: var(--r-amber); + } + + &--danger { + background: var(--r-red-l); + color: var(--r-red); + } +} + +/* What the report says should happen to the rating */ +.rr-verdict-cell { + min-width: 210px; + max-width: 300px; +} + +.rr-verdict { + display: inline-block; + font-size: 11px; + font-weight: 600; + color: var(--r-g500); + white-space: nowrap; + + &--up { + color: var(--r-amber); + } + + /* A rating coming down is good news, not a warning. */ + &--down { + color: var(--r-green); + } + + &--str { + color: var(--r-red); + font-weight: 700; + } +} + +.rr-verdict-why { + font-size: 10.5px; + color: var(--r-g500); + margin-top: 2px; + white-space: normal; +} + +.rr-tag { + display: inline-block; + margin-left: 6px; + padding: 1px 7px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + background: var(--r-g100); + color: var(--r-g700); + white-space: nowrap; + + &--ctr { + background: var(--r-red-l); + color: var(--r-red); + } +} + +.mono { + font-family: 'DM Mono', monospace; + font-size: 11.5px; +} + +.rr-empty { + padding: 44px; + text-align: center; + color: var(--r-g500); + font-size: 13px; +} + +.rr-legend { + font-size: 11.5px; + color: var(--r-g500); + margin: 10px 2px 0; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +/* TOAST */ +.rr-toast { + position: fixed; + bottom: 32px; + left: 50%; + transform: translateX(-50%) translateY(20px); + background: var(--r-navy); + color: #fff; + padding: 12px 22px; + border-radius: 10px; + font-size: 13px; + box-shadow: 0 6px 20px rgb(0 0 0 / 25%); + opacity: 0; + pointer-events: none; + transition: all 0.25s; + z-index: 1500; + + &.show { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +@media (width <= 1200px) { + .rr-kpis { + grid-template-columns: 1fr 1fr; + } +} diff --git a/src/app/risk-review/risk-review-report.component.ts b/src/app/risk-review/risk-review-report.component.ts new file mode 100644 index 000000000..58ef27234 --- /dev/null +++ b/src/app/risk-review/risk-review-report.component.ts @@ -0,0 +1,726 @@ +/** + * 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); + } +} diff --git a/src/app/risk-review/risk-review.compute.ts b/src/app/risk-review/risk-review.compute.ts new file mode 100644 index 000000000..ce0dd9000 --- /dev/null +++ b/src/app/risk-review/risk-review.compute.ts @@ -0,0 +1,489 @@ +/** + * 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, + NON_MITIGABLE_RULES, + 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; +} + +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) => NON_MITIGABLE_RULES.has(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.` + }; + } + + return { risk, reason: '' }; +} + +/** 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. + */ +export function riskCustomerRows(views: RiskAlertView[]): 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'); + + const recommendation = 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'; + + 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' + }); + } + + // 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): RiskReviewModel { + const base = riskAlertViews(alerts, filter); + const narrowed = applyDirectionFilter(base, riskCustomerRows(base), 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')}` }; +} diff --git a/src/app/shared/icons.module.ts b/src/app/shared/icons.module.ts index 8da43cbc0..07f2b7ee5 100644 --- a/src/app/shared/icons.module.ts +++ b/src/app/shared/icons.module.ts @@ -53,6 +53,7 @@ import { faFileUpload, faFileWord, faFillDrip, + faGavel, faGlobe, faHandHoldingUsd, faHome, @@ -297,6 +298,7 @@ export class IconsModule { faFileExcel, faFileExport, faFillDrip, + faGavel, faGlobe, faHandHoldingHand, faHandHoldingHeart, diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 9ef2e50ed..97de74f71 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260801', - 'hash': '2a9df828' + 'version': '260802', + 'hash': '0ca6842b' }, 'allow_switching_backend_instance': true };