risk review report with on which aml rule

This commit is contained in:
2026-08-02 21:40:34 +05:30
parent ebe67cc410
commit b43ed87771
13 changed files with 5098 additions and 327 deletions
+11
View File
@@ -591,6 +591,9 @@
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0"> <button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report <i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
</button> </button>
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
</mat-menu> </mat-menu>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup, <!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
@@ -1803,6 +1806,14 @@
> >
<fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML <fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML
</a> </a>
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('Risk Review Report')"
routerLink="/reports/risk-review"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="user-shield" class="gl-icon"></fa-icon> Risk Review Report
</a>
</div> </div>
<!-- OSS Reports --> <!-- OSS Reports -->
<div class="gl-group"> <div class="gl-group">
+310
View File
@@ -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 ── * ── STR cases ──
* Raised either by the AML rules engine or by an officer flagging a CTR row as * Raised either by the AML rules engine or by an officer flagging a CTR row as
+103 -254
View File
@@ -109,6 +109,52 @@
</div> </div>
</div> </div>
<!-- WHEN TO REPORT — the five PMLA obligations -->
<div class="ob-block">
<div class="ob-head">
<div>
<h3 class="ob-title"><fa-icon icon="gavel"></fa-icon> When to report to FIU-IND</h3>
<p class="ob-sub">
The five reports a reporting entity owes under the Prevention of Money Laundering Act. All are filed
electronically through the FINnet 2.0 portal.
</p>
</div>
</div>
<div class="ob-grid">
@for (o of obligations; track o.code) {
<div class="ob-card" [class.ob-card--off]="!o.handled">
<div class="ob-card-h">
<fa-icon [icon]="o.icon" class="ob-ic"></fa-icon>
<span class="ob-code">{{ o.code }}</span>
<span class="ob-name">{{ o.name }}</span>
@if (o.handled) {
<span class="ob-flag ob-flag--on">Filed here</span>
} @else {
<span class="ob-flag">Tracked</span>
}
</div>
<div class="ob-row">
<label>Report when</label><span>{{ o.trigger }}</span>
</div>
<div class="ob-row">
<label>Deadline</label><span class="ob-due">{{ o.deadline }}</span>
</div>
<div class="ob-note">{{ o.note }}</div>
@if (o.code === 'CTR') {
<button class="dc-btn" (click)="showTab('ctr')">
<fa-icon icon="external-link-alt"></fa-icon> Open CTR
</button>
} @else if (o.code === 'STR') {
<button class="dc-btn" (click)="showTab('str')">
<fa-icon icon="external-link-alt"></fa-icon> Open STR
</button>
}
</div>
}
</div>
</div>
<!-- Compliance cards --> <!-- Compliance cards -->
<div class="fiu-compliance"> <div class="fiu-compliance">
<div class="dc-card"> <div class="dc-card">
@@ -149,148 +195,23 @@
<!-- CTR --> <!-- CTR -->
@if (activeTab === 'ctr') { @if (activeTab === 'ctr') {
<div class="fiu-section"> <div class="fiu-section">
<!-- LIVE: cash-related CTR threshold alerts --> <!-- FINNET FILE SEQUENCE — the CTR submission, in prescribed order -->
<div class="seq-block">
<div class="fiu-form-meta"> <div class="fiu-form-meta">
<div class="fiu-form-title"> <div class="fiu-form-title">
<h3>Live CTR Threshold Alerts</h3> <h3>CTR Submission Files — FINnet 2.0</h3>
<p> <p>
Mechanical cash threshold breaches. CTR is filed regardless of outcome — where the source of funds cannot be The CTR is built in this order: the account is described first, then who is behind it, then the two
evidenced, send the transaction to STR for the Principal Officer to investigate. transaction files. Every grid below is the exact content of its CSV. The customer cards (KC1 / KC2) travel
with the same bundle and are worked on the Customer Cards tab.
</p> </p>
</div> </div>
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()"> <button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
<option value="">All statuses</option> <fa-icon icon="file-csv"></fa-icon> Download full bundle
<option value="RAISED">Raised</option>
<option value="UNDER_REVIEW">Under Review</option>
<option value="WHITELISTED">Whitelisted</option>
<option value="ESCALATED">Escalated</option>
</select>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th>Type</th>
<th>Rule</th>
<th class="ta-r">Amount</th>
<th>Raised On</th>
<th>Status</th>
<th class="ta-c">Routes To</th>
<th>Notes</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (alert of ctrAlerts; track alert.id) {
<tr>
<td>{{ alert.id }}</td>
<td>{{ alert.clientDisplayName }}</td>
<td>{{ alert.transactionType }}</td>
<td>{{ alert.ruleCode }} &mdash; {{ alert.ruleDescription }}</td>
<td class="ta-r mono">&#8377;{{ alert.amount | number: '1.0-0' }}</td>
<td>{{ alert.raisedOn | date: 'medium' }}</td>
<td>
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
</td>
<td class="ta-c">
<span [class]="alertRouteClass(alert.status)">{{ alertRouteLabel(alert.status) }}</span>
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<input
type="text"
class="fiu-inp"
placeholder="Review notes"
[(ngModel)]="amlReviewNotes[alert.id]"
/>
} @else {
<span class="muted">{{ alert.reviewNotes }}</span>
}
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Send to STR</button>
</div>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="fiu-empty">No live alerts.</td>
</tr>
}
</tbody>
</table>
</div>
<p class="fiu-tbl-legend">
<span class="report-pill report-pending">Pending</span> cash breach awaiting review &middot;
<span class="report-pill report-str">Referred to STR</span> source of funds not evidenced — sent to the
Principal Officer, who decides there whether to whitelist it or file an STR
</p>
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>Monthly CTR Reports</h3>
<p>
Threshold: <strong>&#8377;{{ bank.cashThreshold | number }}</strong> per single txn / connected aggregate
per month
</p>
</div>
<div class="fiu-action-group">
<input class="fiu-inp" type="month" [(ngModel)]="ctrGenerateMonth" />
<button class="fiu-btn fiu-btn-g" *mifosxHasPermission="'REVIEW_AML_ALERT'" (click)="generateCtrReport()">
Generate
</button>
</div>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>Month</th>
<th>Generated On</th>
<th class="ta-c">Records</th>
<th>Status</th>
<th>Submitted On</th>
</tr>
</thead>
<tbody>
@for (r of amlCtrReports; track r.id) {
<tr>
<td>{{ r.reportMonth }}</td>
<td>{{ r.generatedOn | date: 'medium' }}</td>
<td class="ta-c">{{ r.recordCount }}</td>
<td>
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="fiu-empty">No CTR reports generated yet.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- FINnet export template (separate concern — TC1/TC2 file-format generation) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>FINnet TC2 Export Template</h3>
<p>
All cash transactions ≥ &#8377;{{ bank.cashThreshold | number }} (single or aggregate per month per account)
</p>
</div>
<button class="fiu-btn fiu-btn-csv" (click)="exportTc2()">
<fa-icon icon="file-csv"></fa-icon> Download TC2.csv
</button> </button>
</div> </div>
<!-- Scope: these controls decide which transactions the six files carry. -->
<div class="fiu-filters"> <div class="fiu-filters">
<div class="fiu-filter"> <div class="fiu-filter">
<label>Type</label> <label>Type</label>
@@ -311,141 +232,69 @@
<button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button> <button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
</div> </div>
<div class="fiu-tbl-wrap"> <!-- Step rail -->
<table class="fiu-tbl"> <div class="seq-rail">
@for (f of finnetSequence; track f.key) {
<button class="seq-step" [class.active]="activeFinnetFile === f.key" (click)="showFinnetFile(f.key)">
<span class="seq-n">{{ f.step }}</span>
<span class="seq-txt">
<strong>{{ f.name }}</strong>
<em>{{ f.table.rows.length }} {{ f.table.rows.length === 1 ? 'row' : 'rows' }}</em>
</span>
</button>
@if (!$last) {
<fa-icon icon="chevron-right" class="seq-arrow"></fa-icon>
}
}
</div>
<!-- Open file -->
<div class="seq-panel">
<div class="seq-panel-h">
<div>
<h4>Step {{ openFinnetFile.step }} — {{ openFinnetFile.title }}</h4>
<p>{{ openFinnetFile.purpose }}</p>
</div>
<button class="fiu-btn fiu-btn-csv" (click)="downloadOpenFinnetFile()">
<fa-icon icon="file-csv"></fa-icon> {{ openFinnetFile.name }}.csv
</button>
</div>
<div class="seq-tbl-wrap">
<table class="seq-tbl">
<thead> <thead>
<tr> <tr>
<th class="ta-c">Ref</th> <th class="seq-rownum">#</th>
<th>Account</th> @for (h of openFinnetFile.table.headers; track $index) {
<th>Customer</th> <th>{{ h }}</th>
<th class="ta-c">Type</th> }
<th>Date / Time</th>
<th class="ta-r">Amount</th>
<th>Instrument</th>
<th>Branch</th>
<th class="ta-c">Relationship</th>
<th class="ta-c">Filed Under</th>
<th>Compliance Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (t of filteredCtr; track t.txnId) { @for (r of openFinnetFile.table.rows; track $index) {
<tr> <tr>
<td class="ta-c">{{ t.reportRefNo }}</td> <td class="seq-rownum">{{ $index + 1 }}</td>
<td class="mono">{{ t.accountNumber }}</td> @for (c of r; track $index) {
<td>{{ t.customerName }}</td> <td>{{ c }}</td>
<td class="ta-c">
<span
class="dir-pill"
[class.deposit]="t.depositOrWithdrawal === 'Deposit'"
[class.withdrawal]="t.depositOrWithdrawal === 'Withdrawal'"
>
{{ t.depositOrWithdrawal }}
</span>
</td>
<td>
{{ t.txnDate }} <span class="muted">{{ t.txnTime }}</span>
</td>
<td class="ta-r mono">
<strong>&#8377;{{ t.txnAmount | number: '1.0-0' }}</strong>
</td>
<td>
{{ t.instrumentType }} <span class="muted">{{ t.instrumentId }}</span>
</td>
<td class="mono">
{{ t.branchCode }} <span class="muted">{{ branchNameFor(t.branchCode) }}</span>
</td>
<td class="ta-c">
<span class="rel-pill" [class.non-cust]="t.relationshipFlag === 'Non-Customer'">{{
t.relationshipFlag
}}</span>
</td>
<td class="ta-c">
<span class="report-pill report-ctr" title="Always included in the monthly CTR / TC2 file">CTR</span>
@if (t.complianceStatus === 'REFERRED') {
<span
class="report-pill report-str"
title="Also under investigation as an STR case — the transaction remains in the CTR file"
>+ STR</span
>
} }
</td>
<td>
@if (t.complianceStatus === 'REFERRED') {
<span class="status-pill status-escalated">REFERRED TO STR</span>
<button class="fiu-link-btn" (click)="openStrCase(t)">View in STR tab &rarr;</button>
} @else if (t.complianceStatus === 'EVIDENCED') {
<span class="status-pill status-whitelisted">EVIDENCE ON FILE</span>
<div class="muted" style="margin-top: 3px">
{{ t.evidenceDocType }}
@if (t.evidenceReference) {
<span> · {{ t.evidenceReference }}</span>
}
</div>
} @else {
<div class="fiu-action-group" style="gap: 4px" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<button class="fiu-btn fiu-btn-evidence" (click)="startCtrEvidence(t)">Record evidence</button>
<button class="fiu-btn fiu-btn-refer" (click)="referCtrTxnToStr(t)">Send to STR</button>
</div>
}
</td>
</tr> </tr>
<!-- Inline evidence capture for this transaction -->
@if (evidencingTxn?.txnId === t.txnId) {
<tr class="evidence-row">
<td colspan="12">
<div class="evidence-panel">
<div class="evidence-t">
<fa-icon icon="check-circle"></fa-icon>
Record source-of-funds evidence for {{ t.customerName }} &mdash; &#8377;{{
t.txnAmount | number: '1.0-0'
}}
</div>
<div class="evidence-grid">
<div class="form-row">
<label for="ctrDoc{{ t.txnId }}">Document produced</label>
<select id="ctrDoc{{ t.txnId }}" class="fiu-inp" [(ngModel)]="ctrEvidenceDocType">
<option value="">Select document…</option>
@for (d of evidenceDocTypes; track d) {
<option [value]="d">{{ d }}</option>
}
</select>
</div>
<div class="form-row">
<label for="ctrRef{{ t.txnId }}">Document / reference number</label>
<input id="ctrRef{{ t.txnId }}" class="fiu-inp" type="text" [(ngModel)]="ctrEvidenceRef" />
</div>
<div class="fiu-action-group">
<button class="fiu-btn fiu-btn-evidence" (click)="confirmCtrEvidence()">
Confirm — CTR only
</button>
<button class="fiu-btn" (click)="cancelCtrEvidence()">Cancel</button>
</div>
</div>
<p class="evidence-note">
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.
</p>
</div>
</td>
</tr>
}
} @empty { } @empty {
<tr> <tr>
<td colspan="11" class="fiu-empty">No transactions match the filters.</td> <td [attr.colspan]="openFinnetFile.table.headers.length + 1" class="seq-empty">
@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.
}
</td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div> </div>
<p class="fiu-tbl-legend"> </div>
<span class="report-pill report-ctr">CTR</span> every cash txn ≥ threshold is reported here regardless of </div>
outcome. <strong>Record evidence</strong> when the customer produces valid documents — CTR only, no STR &middot;
<span class="report-pill report-str">+ STR</span> <strong>Send to STR</strong> 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.
</p>
</div> </div>
} }
+298
View File
@@ -1549,3 +1549,301 @@
color: var(--f-g500); 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;
}
+298 -36
View File
@@ -15,8 +15,10 @@ import { AuthenticationService } from 'app/core/authentication/authentication.se
import { import {
BRANCHES, BRANCHES,
BranchMaster, BranchMaster,
CardTransaction,
CtrTransaction, CtrTransaction,
CTR_TXNS, CTR_TXNS,
TC1_TXNS,
FIU_BANK, FIU_BANK,
FIU_BRANCH_CODE_TO_CANONICAL, FIU_BRANCH_CODE_TO_CANONICAL,
KC1_INDIVIDUALS, KC1_INDIVIDUALS,
@@ -29,6 +31,90 @@ import {
STR_TXNS STR_TXNS
} from './fiu-data'; } 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({ @Component({
selector: 'mifosx-fiu', selector: 'mifosx-fiu',
templateUrl: './fiu.component.html', templateUrl: './fiu.component.html',
@@ -48,8 +134,12 @@ export class FiuComponent implements OnInit {
/** Active tab */ /** Active tab */
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard'; 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; bank = FIU_BANK;
ctrTxns: CtrTransaction[] = CTR_TXNS; ctrTxns: CtrTransaction[] = CTR_TXNS;
tc1Txns: CardTransaction[] = TC1_TXNS;
strTxns: StrTransaction[] = STR_TXNS; strTxns: StrTransaction[] = STR_TXNS;
kc1: KycIndividual[] = KC1_INDIVIDUALS; kc1: KycIndividual[] = KC1_INDIVIDUALS;
kc2: KycEntity[] = KC2_ENTITIES; 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[] { get filteredStr(): StrTransaction[] {
return this.scopedStrTxns.filter((s) => { return this.scopedStrTxns.filter((s) => {
if (this.strFilterStatus && s.status !== this.strFilterStatus) return false; if (this.strFilterStatus && s.status !== this.strFilterStatus) return false;
@@ -797,9 +898,14 @@ export class FiuComponent implements OnInit {
return map[c]; return map[c];
} }
// -------------------- Exports -------------------- // -------------------- FINnet file builders --------------------
/** TC2 — Branch / cash transaction CSV */ /**
exportTc2(): void { * 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 = [ const headers = [
'Report Type', 'Report Type',
'Report Reference Number', 'Report Reference Number',
@@ -818,7 +924,7 @@ export class FiuComponent implements OnInit {
'Third Party PAN (Non-Customer)', 'Third Party PAN (Non-Customer)',
'Declaration (If Third Party PAN (Non-Customer) is not available)' 'Declaration (If Third Party PAN (Non-Customer) is not available)'
]; ];
const rows = [headers]; const rows: string[][] = [];
for (const t of this.filteredCtr) { for (const t of this.filteredCtr) {
rows.push([ rows.push([
t.reportType, t.reportType,
@@ -839,12 +945,11 @@ export class FiuComponent implements OnInit {
t.declaration t.declaration
]); ]);
} }
this.saveCsv(rows, 'TC2.csv'); return { headers, rows };
this.toast(`TC2 exported (${this.filteredCtr.length} rows)`);
} }
/** TC1 header-only template (UI doesn't track ATM/card txns) */ /** TC1 — ATM / card transactions. Header-only: this bank books no card txns. */
exportTc1(): void { private buildTc1(): FinnetTable {
const headers = [ const headers = [
'Relationship Flag', 'Relationship Flag',
'Transaction Status', 'Transaction Status',
@@ -865,12 +970,34 @@ export class FiuComponent implements OnInit {
'Branch Code of Account', 'Branch Code of Account',
'Report Type' 'Report Type'
]; ];
this.saveCsv([headers], 'TC1.csv'); const rows: string[][] = [];
this.toast('TC1 template exported (header only)'); 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 */ /** KC1 — Individual customers */
exportKc1(): void { private buildKc1(): FinnetTable {
const headers = [ const headers = [
'Report Reference Number', 'Report Reference Number',
'UCIC', 'UCIC',
@@ -917,7 +1044,7 @@ export class FiuComponent implements OnInit {
'Other Identifier Number', 'Other Identifier Number',
'Primary Entity' 'Primary Entity'
]; ];
const rows = [headers]; const rows: string[][] = [];
for (const k of this.kc1) { for (const k of this.kc1) {
rows.push([ rows.push([
k.reportRefNo.toString(), k.reportRefNo.toString(),
@@ -966,12 +1093,11 @@ export class FiuComponent implements OnInit {
'Yes' 'Yes'
]); ]);
} }
this.saveCsv(rows, 'KC1.csv'); return { headers, rows };
this.toast(`KC1 exported (${this.kc1.length} individuals)`);
} }
/** KC2 — Entity customers */ /** KC2 — Entity customers */
exportKc2(): void { private buildKc2(): FinnetTable {
const headers = [ const headers = [
'Report Reference Number', 'Report Reference Number',
'UCIC', 'UCIC',
@@ -1006,7 +1132,7 @@ export class FiuComponent implements OnInit {
'UCIC of Owner of Sole Proprietor Firm', 'UCIC of Owner of Sole Proprietor Firm',
'Name 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) { for (const k of this.kc2) {
rows.push([ rows.push([
k.reportRefNo.toString(), k.reportRefNo.toString(),
@@ -1043,12 +1169,11 @@ export class FiuComponent implements OnInit {
k.ownerName k.ownerName
]); ]);
} }
this.saveCsv(rows, 'KC2.csv'); return { headers, rows };
this.toast(`KC2 exported (${this.kc2.length} entities)`);
} }
/** Account_Detail.csv — per-report account aggregates (12-month) */ /** Account_Detail — per-report account aggregates (12-month) */
exportAccountDetail(): void { private buildAccountDetail(): FinnetTable {
const headers = [ const headers = [
'Report Type', 'Report Type',
'Report Reference Number', 'Report Reference Number',
@@ -1067,7 +1192,7 @@ export class FiuComponent implements OnInit {
'Total Cash Deposit (In last 12 months)', 'Total Cash Deposit (In last 12 months)',
'Total Cash Withdrawal (In last 12 months) Amount' 'Total Cash Withdrawal (In last 12 months) Amount'
]; ];
const rows = [headers]; const rows: string[][] = [];
// Build one aggregate row per (reportRefNo, accountNumber) pair // Build one aggregate row per (reportRefNo, accountNumber) pair
const seen = new Set<string>(); const seen = new Set<string>();
for (const t of this.filteredCtr) { for (const t of this.filteredCtr) {
@@ -1105,12 +1230,11 @@ export class FiuComponent implements OnInit {
.toString() .toString()
]); ]);
} }
this.saveCsv(rows, 'Account_Detail.csv'); return { headers, rows };
this.toast(`Account_Detail exported (${rows.length - 1} accounts)`);
} }
/** Account_Person_Relation.csv — account ↔ customer mapping */ /** Account_Person_Relation — account ↔ customer mapping */
exportAccountPersonRelation(): void { private buildAccountPersonRelation(): FinnetTable {
const headers = [ const headers = [
'Report Type', 'Report Type',
'Report Reference Number', 'Report Reference Number',
@@ -1121,7 +1245,7 @@ export class FiuComponent implements OnInit {
'Individual / Non-individual', 'Individual / Non-individual',
'Name of Non-customer' 'Name of Non-customer'
]; ];
const rows = [headers]; const rows: string[][] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const t of this.filteredCtr) { for (const t of this.filteredCtr) {
const key = `${t.reportRefNo}|${t.accountNumber}`; const key = `${t.reportRefNo}|${t.accountNumber}`;
@@ -1140,8 +1264,145 @@ export class FiuComponent implements OnInit {
t.relationshipFlag === 'Non-Customer' ? t.customerName || '' : '' t.relationshipFlag === 'Non-Customer' ? t.customerName || '' : ''
]); ]);
} }
this.saveCsv(rows, 'Account_Person_Relation.csv'); return { headers, rows };
this.toast(`Account_Person_Relation exported (${rows.length - 1} 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) */ /** STR XML stub (FINnet 2.0 STR XML) */
@@ -1187,15 +1448,16 @@ export class FiuComponent implements OnInit {
URL.revokeObjectURL(url); 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 { exportAll(): void {
setTimeout(() => this.exportTc1(), 0); // All six travel together — the customer cards are not walked through in the
setTimeout(() => this.exportTc2(), 200); // CTR tab, but a bundle without them would be incomplete.
setTimeout(() => this.exportKc1(), 400); const files = this.allFinnetFiles;
setTimeout(() => this.exportKc2(), 600); files.forEach((f, i) => setTimeout(() => this.saveTable(f), i * 200));
setTimeout(() => this.exportAccountDetail(), 800); setTimeout(() => this.exportStrXml(), files.length * 200);
setTimeout(() => this.exportAccountPersonRelation(), 1000);
setTimeout(() => this.exportStrXml(), 1200);
} }
private escapeXml(s: string): string { private escapeXml(s: string): string {
@@ -53,6 +53,12 @@ const routes: Routes = [
(m) => m.FormDeclarationReportComponent (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', path: ':filter',
data: { routeParamBreadcrumb: 'filter' }, data: { routeParamBreadcrumb: 'filter' },
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,527 @@
<!--
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/.
-->
<div class="rr-root">
<!-- HEADER -->
<div class="rr-head">
<div class="rr-head-left">
<fa-icon icon="user-shield" class="rr-head-ic"></fa-icon>
<div>
<h2 class="rr-head-t">Risk Review Report</h2>
<div class="rr-head-s">
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.
</div>
</div>
</div>
<div class="rr-head-actions">
<button class="rr-btn rr-btn--primary" [disabled]="generating || loading" (click)="exportExcel()">
<fa-icon icon="file-excel"></fa-icon> {{ generating ? 'Generating…' : 'Download Excel' }}
</button>
</div>
</div>
@if (sampleCount) {
<div class="rr-banner rr-banner--sample">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
<strong>{{ sampleCount }} demonstration rows</strong> are included alongside
{{ hasLive ? 'the live alerts' : 'the live feed, which has raised nothing' }}. Sample rows carry a
<span class="rr-tag">Sample</span> tag in the table and must not be acted on as compliance data.
</span>
<button class="rr-btn rr-btn--slim" (click)="load()"><fa-icon icon="undo"></fa-icon> Reload live feed</button>
</div>
}
<!-- FILTERS -->
<div class="rr-filters">
<div class="rr-filter-row">
<div class="rr-modes">
@for (m of periodModes; track m.key) {
<button class="rr-chip" [class.active]="periodMode === m.key" (click)="periodMode = m.key">
{{ m.label }}
</button>
}
</div>
@switch (periodMode) {
@case ('day') {
<label class="rr-field">
<span>Date</span>
<input type="date" [(ngModel)]="filterDay" />
</label>
}
@case ('week') {
<label class="rr-field">
<span>Any date in week</span>
<input type="date" [(ngModel)]="filterWeek" />
</label>
<span class="rr-resolved">Week: {{ rangeLabel }}</span>
}
@case ('month') {
<label class="rr-field">
<span>Month</span>
<input type="month" [(ngModel)]="filterMonth" />
</label>
}
@default {
<label class="rr-field">
<span>Alerts from</span>
<input type="date" [(ngModel)]="filterFrom" />
</label>
<label class="rr-field">
<span>To</span>
<input type="date" [(ngModel)]="filterTo" />
</label>
}
}
</div>
<div class="rr-filter-row">
<label class="rr-field">
<span>Branch</span>
<select [(ngModel)]="branchCode" [disabled]="!canSelectBranch">
@if (canSelectBranch) {
<option value="">All Branches</option>
}
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
}
</select>
</label>
<label class="rr-field">
<span>Current risk</span>
<select [(ngModel)]="currentRisk">
<option value="">Any rating</option>
<option value="LOW">Low</option>
<option value="MEDIUM">Medium</option>
<option value="HIGH">High</option>
</select>
</label>
<label class="rr-field">
<span>AML rule triggered</span>
<select [(ngModel)]="ruleCode">
<option value="">All rules</option>
@for (r of rules; track r.code) {
<option [value]="r.code">{{ r.label }}</option>
}
</select>
</label>
<label class="rr-field">
<span>Review status</span>
<select [(ngModel)]="status">
<option value="">Any status</option>
@for (s of reviewStatuses; track s) {
<option [value]="s">{{ statusMeta[s].label }}</option>
}
</select>
</label>
<label class="rr-field">
<span>Rating outcome</span>
<select [(ngModel)]="direction">
<option value="">Any outcome</option>
<option value="up">Review upward</option>
<option value="down">Review downward</option>
<option value="same">Within current rating</option>
</select>
</label>
<label class="rr-field rr-field--grow">
<span>Search</span>
<input type="text" [(ngModel)]="search" placeholder="Customer name, customer ID, account number or alert ID…" />
</label>
<label class="rr-check">
<input type="checkbox" [(ngModel)]="openOnly" />
Only cases awaiting review
</label>
<button class="rr-btn" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
</div>
@if (!canSelectBranch) {
<div class="rr-scope-note">
<fa-icon icon="lock"></fa-icon>
Showing {{ branchLabel }} only. Head Office logins can select any branch.
</div>
}
</div>
@if (loading) {
<div class="rr-loading"><fa-icon icon="shield-alt"></fa-icon> Loading AML alerts…</div>
} @else {
<!-- SUMMARY -->
<div class="rr-kpis">
<div class="rr-kpi">
<div class="rr-kpi-n">{{ summary.customers }}</div>
<div class="rr-kpi-l">Customers for review</div>
<div class="rr-kpi-sub">
{{ summary.byRisk.LOW }} Low · {{ summary.byRisk.MEDIUM }} Medium · {{ summary.byRisk.HIGH }} High
</div>
</div>
<div class="rr-kpi rr-kpi--blue">
<div class="rr-kpi-n">{{ summary.alerts }}</div>
<div class="rr-kpi-l">AML alerts triggered</div>
<div class="rr-kpi-sub">on {{ summary.txns }} transactions · {{ summary.openAlerts }} awaiting a decision</div>
</div>
<div
class="rr-kpi rr-kpi--amber"
[class.rr-kpi--dim]="summary.upgradeCandidates + summary.downgradeCandidates === 0"
>
<div class="rr-kpi-n">{{ summary.upgradeCandidates + summary.downgradeCandidates }}</div>
<div class="rr-kpi-l">Ratings to be moved</div>
<div class="rr-kpi-sub">
{{ summary.upgradeCandidates }} upward · {{ summary.downgradeCandidates }} downward
</div>
</div>
<div class="rr-kpi" [class.rr-kpi--red]="summary.escalatedCustomers > 0">
<div class="rr-kpi-n">{{ summary.escalatedCustomers }}</div>
<div class="rr-kpi-l">Escalated to STR</div>
<div class="rr-kpi-sub">₹{{ summary.totalAmount | number: '1.0-0' }} under review</div>
</div>
</div>
<!-- RULE MIX — doubles as a one-click filter -->
@if (summary.byRule.length) {
<div class="rr-mix">
<span class="rr-mix-t">Rules triggered</span>
@for (r of summary.byRule; track r.code) {
<button class="rr-mix-item" [class.active]="ruleCode === r.code" (click)="toggleRule(r.code)">
{{ r.label }} <strong>{{ r.count }}</strong>
</button>
}
</div>
}
<!-- VIEW SWITCH -->
<div class="rr-views">
<button class="rr-chip" [class.active]="view === 'customer'" (click)="view = 'customer'">
<fa-icon icon="users"></fa-icon> Customer wise
</button>
<button class="rr-chip" [class.active]="view === 'alert'" (click)="view = 'alert'">
<fa-icon icon="list"></fa-icon> Alert wise
</button>
<button class="rr-chip" [class.active]="view === 'txn'" (click)="view = 'txn'">
<fa-icon icon="filter"></fa-icon> Transaction wise
</button>
<span class="rr-spacer"></span>
<span class="rr-count">
{{
view === 'customer'
? customerRows.length + ' customers'
: view === 'alert'
? alertRows.length + ' alerts'
: txnRows.length + ' transactions'
}}
</span>
</div>
<!-- CUSTOMER WISE -->
@if (view === 'customer') {
<div class="rr-table-wrap">
<table class="rr-table">
<thead>
<tr>
<th></th>
<th>Customer ID</th>
<th>Customer Name</th>
<th>Account Number</th>
<th>Branch</th>
<th>Current Risk</th>
<th class="num">Alerts</th>
<th>AML Rules Triggered</th>
<th class="num">Amount under review</th>
<th>Last Alert</th>
<th>Position</th>
</tr>
</thead>
<tbody>
@for (c of customerRows; track c.clientId) {
<tr
class="rr-cust"
[class.open]="expanded === c.clientId"
[class.rr-row--escalated]="c.escalated"
(click)="toggle(c.clientId)"
>
<td>
<fa-icon [icon]="expanded === c.clientId ? 'caret-down' : 'caret-right'"></fa-icon>
</td>
<td class="mono">{{ c.clientId }}</td>
<td>
<strong>{{ c.customerName }}</strong>
@if (c.openCount > 0) {
<span class="rr-tag">{{ c.openCount }} open</span>
}
</td>
<td class="mono">{{ c.accountNo || '—' }}</td>
<td>{{ c.branchCode || '—' }}</td>
<td>
<span [class]="riskClass(c.currentRisk)">{{ riskText(c.currentRisk) }}</span>
</td>
<td class="num">{{ c.alertCount }}</td>
<td class="rr-rules-cell">
@for (label of c.ruleLabels; track label) {
<span class="rr-rule">{{ label }}</span>
}
</td>
<td class="num strong">{{ c.totalAmount | number: '1.0-0' }}</td>
<td class="mono">{{ c.lastAlertDate }}</td>
<td class="rr-verdict-cell">
<span [class]="verdictClass(c)">{{ verdictText(c) }}</span>
@if (c.directionReason) {
<div class="rr-verdict-why">{{ c.directionReason }}</div>
}
</td>
</tr>
@if (expanded === c.clientId) {
<tr class="rr-detail">
<td colspan="11">
<!-- One block per alert: what the rule is, then the entries it fired on. -->
@for (a of c.alerts; track a.alertId) {
<div class="rr-case">
<div class="rr-case-head">
<span class="rr-rule">{{ a.rule.label }}</span>
<span class="rr-rule-cat">{{ a.rule.category }}</span>
<span class="rr-case-id mono">{{ a.alertId }} · raised {{ a.alertDate }}</span>
<span class="rr-spacer"></span>
<span class="rr-case-amt mono">₹{{ a.amount | number: '1.0-0' }}</span>
<span [class]="statusClass(a.status)">{{ statusMeta[a.status].label }}</span>
</div>
<div class="rr-case-why">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span><strong>Why it triggered:</strong> {{ a.rule.description }} — {{ txnDetail(a) }}</span>
</div>
<table class="rr-subtable">
<thead>
<tr>
<th>Txn Date</th>
<th>Txn ID</th>
<th>Channel</th>
<th>Dr/Cr</th>
<th class="num">Amount</th>
<th class="num">Balance After</th>
<th>Narration</th>
<th>Counterparty</th>
<th>AML Rule Triggered</th>
</tr>
</thead>
<tbody>
@for (t of a.txns; track t.txnId) {
<tr [class.rr-leg--ctr]="t.ctrReportable">
<td class="mono">{{ t.date }}</td>
<td class="mono">{{ t.txnId }}</td>
<td>
<span class="rr-chan">{{ channelLabel(t.channel) }}</span>
</td>
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
<td class="num strong">
{{ t.amount | number: '1.0-0' }}
@if (t.ctrReportable) {
<span class="rr-tag rr-tag--ctr">CTR</span>
}
</td>
<td class="num">{{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}</td>
<td>{{ t.narration || '—' }}</td>
<td>{{ t.counterparty || '—' }}</td>
<td>
<span class="rr-rule">{{ a.rule.label }}</span>
@if (a.txns.length > 1) {
<span class="rr-leg">leg {{ $index + 1 }} of {{ a.txns.length }}</span>
}
</td>
</tr>
}
</tbody>
</table>
<div class="rr-case-foot">
<div class="rr-suggest">
<fa-icon icon="clipboard-check"></fa-icon> {{ a.rule.suggestedAction }}
</div>
<div class="rr-case-remark">
<strong>Reviewer:</strong> {{ a.reviewedBy || 'not yet assigned' }} —
{{ a.remarks || 'no remarks recorded' }}
</div>
</div>
</div>
}
</td>
</tr>
}
} @empty {
<tr>
<td colspan="11" class="rr-empty">
No customer tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ALERT WISE -->
@if (view === 'alert') {
<div class="rr-table-wrap">
<table class="rr-table">
<thead>
<tr>
<th>Alert ID</th>
<th>Alert Date</th>
<th>Customer ID</th>
<th>Customer Name</th>
<th>Account Number</th>
<th>Branch</th>
<th>Current Risk</th>
<th>Transaction Details</th>
<th>AML Rule Triggered</th>
<th class="num">Amount</th>
<th>Review Status</th>
<th>Reviewer Remarks</th>
</tr>
</thead>
<tbody>
@for (a of alertRows; track a.alertId) {
<tr [class.rr-row--escalated]="a.status === 'ESCALATED_STR' || a.status === 'STR_FILED'">
<td class="mono">{{ a.alertId }}</td>
<td class="mono">{{ a.alertDate }}</td>
<td class="mono">{{ a.clientId }}</td>
<td>
<strong>{{ a.customerName }}</strong>
@if (!a.live) {
<span class="rr-tag">Sample</span>
}
</td>
<td class="mono">{{ a.accountNo || '—' }}</td>
<td>{{ a.branchCode || '—' }}</td>
<td>
<span [class]="riskClass(a.currentRisk)">{{ riskText(a.currentRisk) }}</span>
</td>
<td>{{ txnDetail(a) }}</td>
<td class="rr-rules-cell">
<span class="rr-rule">{{ a.rule.label }}</span>
<span class="rr-rule-cat">{{ a.rule.category }}</span>
<div class="rr-rule-desc">{{ a.rule.description }}</div>
</td>
<td class="num strong">{{ a.amount | number: '1.0-0' }}</td>
<td>
<span [class]="statusClass(a.status)">{{ statusMeta[a.status].label }}</span>
@if (a.reviewedBy) {
<div class="rr-by">by {{ a.reviewedBy }}</div>
}
</td>
<td>
{{ a.remarks || '—' }}
<div class="rr-suggest"><fa-icon icon="clipboard-check"></fa-icon> {{ a.rule.suggestedAction }}</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="12" class="rr-empty">
No AML rule was triggered for {{ rangeLabel }} at {{ branchLabel }}.
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- TRANSACTION WISE — every entry, and the rule it tripped -->
@if (view === 'txn') {
<div class="rr-table-wrap">
<table class="rr-table">
<thead>
<tr>
<th>Txn Date</th>
<th>Txn ID</th>
<th>Customer ID</th>
<th>Customer Name</th>
<th>Account Number</th>
<th>Branch</th>
<th>Current Risk</th>
<th>Channel</th>
<th>Dr/Cr</th>
<th class="num">Amount</th>
<th class="num">Balance After</th>
<th>Narration</th>
<th>Counterparty</th>
<th>AML Rule Triggered</th>
<th>Alert</th>
<th>Review Status</th>
</tr>
</thead>
<tbody>
@for (t of txnRows; track t.txnId) {
<tr [class.rr-row--escalated]="t.status === 'ESCALATED_STR' || t.status === 'STR_FILED'">
<td class="mono">{{ t.date }}</td>
<td class="mono">{{ t.txnId }}</td>
<td class="mono">{{ t.clientId }}</td>
<td>
<strong>{{ t.customerName }}</strong>
</td>
<td class="mono">{{ t.accountNo || '—' }}</td>
<td>{{ t.branchCode || '—' }}</td>
<td>
<span [class]="riskClass(t.currentRisk)">{{ riskText(t.currentRisk) }}</span>
</td>
<td>
<span class="rr-chan">{{ channelLabel(t.channel) }}</span>
</td>
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
<td class="num strong">
{{ t.amount | number: '1.0-0' }}
@if (t.ctrReportable) {
<span class="rr-tag rr-tag--ctr">CTR</span>
}
</td>
<td class="num">{{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}</td>
<td>{{ t.narration || '—' }}</td>
<td>{{ t.counterparty || '—' }}</td>
<td class="rr-rules-cell">
<span class="rr-rule">{{ t.rule.label }}</span>
<span class="rr-rule-cat">{{ t.rule.category }}</span>
@if (t.legCount > 1) {
<span class="rr-leg">leg {{ t.legIndex }} of {{ t.legCount }}</span>
}
<div class="rr-rule-desc">{{ t.rule.description }}</div>
</td>
<td class="mono">{{ t.alertId }}</td>
<td>
<span [class]="statusClass(t.status)">{{ statusMeta[t.status].label }}</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="16" class="rr-empty">
No transaction tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.
</td>
</tr>
}
</tbody>
</table>
</div>
}
<p class="rr-legend">
<span class="rr-status rr-status--open">Pending review</span> awaiting a reviewer ·
<span class="rr-status rr-status--ok">Risk retained</span> explained, rating unchanged ·
<span class="rr-status rr-status--warn">Risk upgraded / KYC update</span> profile changed ·
<span class="rr-status rr-status--danger">Escalated / STR filed</span> with the Principal Officer
</p>
}
<div class="rr-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div>
@@ -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;
}
}
@@ -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<void> {
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);
}
}
+489
View File
@@ -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<number, RiskLevel> = { 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<number, RiskAlertView[]>();
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<string, { label: string; count: number }>();
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<ReviewStatus, number>();
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')}` };
}
+2
View File
@@ -53,6 +53,7 @@ import {
faFileUpload, faFileUpload,
faFileWord, faFileWord,
faFillDrip, faFillDrip,
faGavel,
faGlobe, faGlobe,
faHandHoldingUsd, faHandHoldingUsd,
faHome, faHome,
@@ -297,6 +298,7 @@ export class IconsModule {
faFileExcel, faFileExcel,
faFileExport, faFileExport,
faFillDrip, faFillDrip,
faGavel,
faGlobe, faGlobe,
faHandHoldingHand, faHandHoldingHand,
faHandHoldingHeart, faHandHoldingHeart,
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */ /* tslint:disable */
export default { export default {
'mifos_x': { 'mifos_x': {
'version': '260801', 'version': '260802',
'hash': '2a9df828' 'hash': '0ca6842b'
}, },
'allow_switching_backend_instance': true 'allow_switching_backend_instance': true
}; };