+ The five reports a reporting entity owes under the Prevention of Money Laundering Act. All are filed
+ electronically through the FINnet 2.0 portal.
+
- 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.
-
- 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
-
-
-
-
-
-
-
-
-
FINnet TC2 Export Template
-
- All cash transactions ≥ ₹{{ bank.cashThreshold | number }} (single or aggregate per month per account)
-
-
- Record source-of-funds evidence for {{ t.customerName }} — ₹{{
- t.txnAmount | number: '1.0-0'
- }}
-
-
-
-
-
-
-
-
-
-
-
-
- Confirm — CTR only
-
- Cancel
-
-
-
- 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.
-
+ @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 ·
- + STRSend 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.
+
+
+
+ {{ 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.
+
+ Reload live feed
+