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">
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
</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>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
@@ -1803,6 +1806,14 @@
>
<fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML
</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>
<!-- OSS Reports -->
<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 ──
* Raised either by the AML rules engine or by an officer flagging a CTR row as
+138 -289
View File
@@ -109,6 +109,52 @@
</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 -->
<div class="fiu-compliance">
<div class="dc-card">
@@ -149,303 +195,106 @@
<!-- CTR -->
@if (activeTab === 'ctr') {
<div class="fiu-section">
<!-- LIVE: cash-related CTR threshold alerts -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>Live CTR Threshold Alerts</h3>
<p>
Mechanical cash threshold breaches. CTR is filed regardless of outcome — where the source of funds cannot be
evidenced, send the transaction to STR for the Principal Officer to investigate.
</p>
</div>
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
<option value="">All statuses</option>
<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
<!-- FINNET FILE SEQUENCE — the CTR submission, in prescribed order -->
<div class="seq-block">
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>CTR Submission Files — FINnet 2.0</h3>
<p>
The CTR is built in this order: the account is described first, then who is behind it, then the two
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>
</div>
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
<fa-icon icon="file-csv"></fa-icon> Download full bundle
</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>
<!-- Scope: these controls decide which transactions the six files carry. -->
<div class="fiu-filters">
<div class="fiu-filter">
<label>Type</label>
<select class="fiu-inp" [(ngModel)]="ctrFilterMode">
<option value="">All</option>
<option value="Deposit">Deposit</option>
<option value="Withdrawal">Withdrawal</option>
</select>
</div>
<div class="fiu-filter">
<label>Account contains</label>
<input class="fiu-inp" type="text" [(ngModel)]="ctrFilterAccount" placeholder="A/c partial" />
</div>
<div class="fiu-filter">
<label>Min Amount (&#8377;)</label>
<input class="fiu-inp" type="number" [(ngModel)]="ctrFilterMinAmount" placeholder="0" />
</div>
<button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
</div>
<!-- Step rail -->
<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>
}
</tbody>
</table>
</div>
}
</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>
</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="fiu-filters">
<div class="fiu-filter">
<label>Type</label>
<select class="fiu-inp" [(ngModel)]="ctrFilterMode">
<option value="">All</option>
<option value="Deposit">Deposit</option>
<option value="Withdrawal">Withdrawal</option>
</select>
</div>
<div class="fiu-filter">
<label>Account contains</label>
<input class="fiu-inp" type="text" [(ngModel)]="ctrFilterAccount" placeholder="A/c partial" />
</div>
<div class="fiu-filter">
<label>Min Amount (&#8377;)</label>
<input class="fiu-inp" type="number" [(ngModel)]="ctrFilterMinAmount" placeholder="0" />
</div>
<button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th class="ta-c">Ref</th>
<th>Account</th>
<th>Customer</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>
</thead>
<tbody>
@for (t of filteredCtr; track t.txnId) {
<tr>
<td class="ta-c">{{ t.reportRefNo }}</td>
<td class="mono">{{ t.accountNumber }}</td>
<td>{{ t.customerName }}</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
>
<div class="seq-tbl-wrap">
<table class="seq-tbl">
<thead>
<tr>
<th class="seq-rownum">#</th>
@for (h of openFinnetFile.table.headers; track $index) {
<th>{{ h }}</th>
}
</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>
<!-- 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 {
<tr>
<td colspan="11" class="fiu-empty">No transactions match the filters.</td>
</tr>
}
</tbody>
</table>
</thead>
<tbody>
@for (r of openFinnetFile.table.rows; track $index) {
<tr>
<td class="seq-rownum">{{ $index + 1 }}</td>
@for (c of r; track $index) {
<td>{{ c }}</td>
}
</tr>
} @empty {
<tr>
<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>
}
</tbody>
</table>
</div>
</div>
</div>
<p class="fiu-tbl-legend">
<span class="report-pill report-ctr">CTR</span> every cash txn ≥ threshold is reported here regardless of
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>
}
+298
View File
@@ -1549,3 +1549,301 @@
color: var(--f-g500);
}
}
/* ═══ WHEN TO REPORT — the five PMLA obligations ═══ */
.ob-block {
margin: 18px 0 20px;
}
.ob-head {
margin-bottom: 12px;
}
.ob-title {
margin: 0;
font-size: 15px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.ob-sub {
margin: 3px 0 0;
font-size: 12px;
color: #64748b;
max-width: 860px;
}
.ob-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 12px;
}
.ob-card {
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #1a237e;
border-radius: 11px;
padding: 13px 15px;
display: flex;
flex-direction: column;
gap: 7px;
/* Obligations the module only tracks read back a shade quieter than the
two it actually files, so the distinction is visible at a glance. */
&--off {
border-left-color: #cbd5e1;
background: #fcfcfd;
}
}
.ob-card-h {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ob-ic {
color: #1a237e;
font-size: 15px;
}
.ob-code {
font-weight: 800;
font-size: 13px;
letter-spacing: 0.02em;
}
.ob-name {
font-size: 12px;
color: #334155;
font-weight: 600;
}
.ob-flag {
margin-left: auto;
font-size: 10px;
font-weight: 700;
padding: 1px 8px;
border-radius: 999px;
background: #f1f5f9;
color: #64748b;
white-space: nowrap;
&--on {
background: #dcfce7;
color: #15803d;
}
}
.ob-row {
display: flex;
flex-direction: column;
gap: 1px;
label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
span {
font-size: 12.5px;
color: #0f172a;
line-height: 1.45;
}
}
.ob-due {
font-weight: 700;
color: #b45309 !important;
}
.ob-note {
font-size: 11.5px;
color: #64748b;
font-style: italic;
border-top: 1px dashed #e2e8f0;
padding-top: 6px;
}
/* ═══ FINNET FILE SEQUENCE ═══ */
.seq-block {
margin-top: 26px;
padding-top: 18px;
border-top: 2px solid #e2e8f0;
}
.seq-rail {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.seq-step {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 7px 13px;
border: 1px solid #cbd5e1;
background: #fff;
border-radius: 9px;
cursor: pointer;
font-family: inherit;
text-align: left;
transition: all 0.15s;
&:hover {
border-color: #1a237e;
}
&.active {
background: #1a237e;
border-color: #1a237e;
.seq-n {
background: #fff;
color: #1a237e;
}
.seq-txt strong,
.seq-txt em {
color: #fff;
}
}
}
.seq-n {
display: inline-flex;
align-items: center;
justify-content: center;
width: 21px;
height: 21px;
border-radius: 50%;
background: #1a237e;
color: #fff;
font-size: 11px;
font-weight: 800;
flex: 0 0 21px;
}
.seq-txt {
display: flex;
flex-direction: column;
line-height: 1.25;
strong {
font-size: 12.5px;
color: #0f172a;
font-weight: 700;
}
em {
font-size: 10.5px;
color: #64748b;
font-style: normal;
}
}
.seq-arrow {
color: #cbd5e1;
font-size: 11px;
}
.seq-panel {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 11px;
overflow: hidden;
}
.seq-panel-h {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 12px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
flex-wrap: wrap;
h4 {
margin: 0;
font-size: 13.5px;
font-weight: 700;
}
p {
margin: 2px 0 0;
font-size: 12px;
color: #64748b;
max-width: 780px;
}
}
.seq-tbl-wrap {
max-height: 460px;
overflow: auto;
}
.seq-tbl {
width: 100%;
border-collapse: collapse;
font-size: 11.5px;
white-space: nowrap;
th {
position: sticky;
top: 0;
z-index: 1;
text-align: left;
padding: 8px 10px;
background: #eef2f7;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #334155;
border-bottom: 1px solid #cbd5e1;
}
td {
padding: 6px 10px;
border-bottom: 1px solid #f1f5f9;
font-family: 'DM Mono', monospace;
color: #0f172a;
}
tbody tr:hover td {
background: #f8fafc;
}
}
.seq-rownum {
color: #94a3b8 !important;
font-weight: 700;
text-align: right;
position: sticky;
left: 0;
background: #fff;
}
.seq-tbl thead .seq-rownum {
background: #eef2f7;
}
.seq-empty {
padding: 36px !important;
text-align: center;
color: #64748b;
font-family: inherit !important;
white-space: normal;
font-size: 12.5px;
}
+298 -36
View File
@@ -15,8 +15,10 @@ import { AuthenticationService } from 'app/core/authentication/authentication.se
import {
BRANCHES,
BranchMaster,
CardTransaction,
CtrTransaction,
CTR_TXNS,
TC1_TXNS,
FIU_BANK,
FIU_BRANCH_CODE_TO_CANONICAL,
KC1_INDIVIDUALS,
@@ -29,6 +31,90 @@ import {
STR_TXNS
} from './fiu-data';
/** A FINnet file rendered as a grid — the same rows that go into the CSV. */
export interface FinnetTable {
headers: string[];
rows: string[][];
}
export interface FinnetFile {
key: string;
/** File name without the extension, as FIU-IND names it. */
name: string;
/** Position in the prescribed submission order. */
step: number;
title: string;
purpose: string;
table: FinnetTable;
}
/**
* The five reports a reporting entity owes FIU-IND under the PMLA, with the
* trigger for each. All of them are filed electronically through FINnet 2.0.
*/
export interface FiuObligation {
code: string;
name: string;
trigger: string;
deadline: string;
icon: string;
/** Whether this module currently files the report or only tracks the rule. */
handled: boolean;
note: string;
}
export const FIU_OBLIGATIONS: FiuObligation[] = [
{
code: 'CTR',
name: 'Cash Transaction Report',
trigger:
'Cash transactions exceeding ₹10,00,000 in a calendar month — a single transaction or a series of connected ones.',
deadline: 'By the 15th of the following month',
icon: 'coins',
handled: true,
note: 'Filed from the CTR tab as the six-file FINnet bundle.'
},
{
code: 'STR',
name: 'Suspicious Transaction Report',
trigger:
'Any transaction that gives rise to a reasonable ground of suspicion of money laundering or a related offence — regardless of amount, and whether or not it was completed.',
deadline: 'Within 7 working days of forming the suspicion',
icon: 'exclamation-triangle',
handled: true,
note: 'Filed from the STR tab as FINnet STR XML.'
},
{
code: 'CBWTR',
name: 'Cross-Border Wire Transfer Report',
trigger:
'Cross-border wire transfers exceeding ₹5,00,000, where the origin or destination of the funds is outside India.',
deadline: 'By the 15th of the following month',
icon: 'globe',
handled: false,
note: 'Not applicable — this bank holds no AD licence and books no cross-border wires.'
},
{
code: 'CCR',
name: 'Counterfeit Currency Report',
trigger:
'Any forged or counterfeit currency note detected, at the counter or in a remittance. No monetary threshold applies.',
deadline: 'By the 15th of the following month',
icon: 'money-bill-wave',
handled: false,
note: 'Raised from the branch counterfeit-note register; not yet wired into this module.'
},
{
code: 'NTR',
name: 'Non-Profit Organisation Transaction Report',
trigger: 'Receipts by a non-profit organisation exceeding ₹10,00,000, or its equivalent in foreign currency.',
deadline: 'By the 15th of the following month',
icon: 'hand-holding-heart',
handled: false,
note: 'Depends on NPO flagging in the customer master; not yet wired into this module.'
}
];
@Component({
selector: 'mifosx-fiu',
templateUrl: './fiu.component.html',
@@ -48,8 +134,12 @@ export class FiuComponent implements OnInit {
/** Active tab */
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
/** The five PMLA reporting obligations, shown on the FIU landing tab. */
readonly obligations = FIU_OBLIGATIONS;
bank = FIU_BANK;
ctrTxns: CtrTransaction[] = CTR_TXNS;
tc1Txns: CardTransaction[] = TC1_TXNS;
strTxns: StrTransaction[] = STR_TXNS;
kc1: KycIndividual[] = KC1_INDIVIDUALS;
kc2: KycEntity[] = KC2_ENTITIES;
@@ -748,6 +838,17 @@ export class FiuComponent implements OnInit {
});
}
/** ATM / card legs under the same scope controls, so TC1 and TC2 stay in step. */
get filteredTc1(): CardTransaction[] {
const acc = this.ctrFilterAccount.trim();
return this.tc1Txns.filter((c) => {
if (this.ctrFilterMode && c.depositOrWithdrawal !== this.ctrFilterMode) return false;
if (acc && !c.accountNumber.includes(acc)) return false;
if (this.ctrFilterMinAmount > 0 && c.txnAmount < this.ctrFilterMinAmount) return false;
return true;
});
}
get filteredStr(): StrTransaction[] {
return this.scopedStrTxns.filter((s) => {
if (this.strFilterStatus && s.status !== this.strFilterStatus) return false;
@@ -797,9 +898,14 @@ export class FiuComponent implements OnInit {
return map[c];
}
// -------------------- Exports --------------------
/** TC2 — Branch / cash transaction CSV */
exportTc2(): void {
// -------------------- FINnet file builders --------------------
/**
* Each FINnet file is built once, here, and used for both the on-screen
* preview and the CSV download. Keeping a single builder per file is what
* guarantees the branch sees on screen exactly what is submitted — the two
* used to be written out separately, which let them drift.
*/
private buildTc2(): FinnetTable {
const headers = [
'Report Type',
'Report Reference Number',
@@ -818,7 +924,7 @@ export class FiuComponent implements OnInit {
'Third Party PAN (Non-Customer)',
'Declaration (If Third Party PAN (Non-Customer) is not available)'
];
const rows = [headers];
const rows: string[][] = [];
for (const t of this.filteredCtr) {
rows.push([
t.reportType,
@@ -839,12 +945,11 @@ export class FiuComponent implements OnInit {
t.declaration
]);
}
this.saveCsv(rows, 'TC2.csv');
this.toast(`TC2 exported (${this.filteredCtr.length} rows)`);
return { headers, rows };
}
/** TC1 header-only template (UI doesn't track ATM/card txns) */
exportTc1(): void {
/** TC1 — ATM / card transactions. Header-only: this bank books no card txns. */
private buildTc1(): FinnetTable {
const headers = [
'Relationship Flag',
'Transaction Status',
@@ -865,12 +970,34 @@ export class FiuComponent implements OnInit {
'Branch Code of Account',
'Report Type'
];
this.saveCsv([headers], 'TC1.csv');
this.toast('TC1 template exported (header only)');
const rows: string[][] = [];
for (const c of this.filteredTc1) {
rows.push([
c.relationshipFlag,
c.txnStatus,
c.txnId.toString(),
c.txnDate.replace(/-/g, '/'),
c.txnTime,
c.depositOrWithdrawal,
c.txnAmount.toString(),
c.accountNumber,
c.accountType,
c.cardType,
c.cardNumber,
c.cardIssuingCountry,
c.cardIssuingBank,
c.reportRefNo.toString(),
c.switchTxnId,
c.atmId,
c.branchCode,
c.reportType
]);
}
return { headers, rows };
}
/** KC1 — Individual customers */
exportKc1(): void {
private buildKc1(): FinnetTable {
const headers = [
'Report Reference Number',
'UCIC',
@@ -917,7 +1044,7 @@ export class FiuComponent implements OnInit {
'Other Identifier Number',
'Primary Entity'
];
const rows = [headers];
const rows: string[][] = [];
for (const k of this.kc1) {
rows.push([
k.reportRefNo.toString(),
@@ -966,12 +1093,11 @@ export class FiuComponent implements OnInit {
'Yes'
]);
}
this.saveCsv(rows, 'KC1.csv');
this.toast(`KC1 exported (${this.kc1.length} individuals)`);
return { headers, rows };
}
/** KC2 — Entity customers */
exportKc2(): void {
private buildKc2(): FinnetTable {
const headers = [
'Report Reference Number',
'UCIC',
@@ -1006,7 +1132,7 @@ export class FiuComponent implements OnInit {
'UCIC of Owner of Sole Proprietor Firm',
'Name of Owner of Sole Proprietor Firm'
];
const rows = [headers];
const rows: string[][] = [];
for (const k of this.kc2) {
rows.push([
k.reportRefNo.toString(),
@@ -1043,12 +1169,11 @@ export class FiuComponent implements OnInit {
k.ownerName
]);
}
this.saveCsv(rows, 'KC2.csv');
this.toast(`KC2 exported (${this.kc2.length} entities)`);
return { headers, rows };
}
/** Account_Detail.csv — per-report account aggregates (12-month) */
exportAccountDetail(): void {
/** Account_Detail — per-report account aggregates (12-month) */
private buildAccountDetail(): FinnetTable {
const headers = [
'Report Type',
'Report Reference Number',
@@ -1067,7 +1192,7 @@ export class FiuComponent implements OnInit {
'Total Cash Deposit (In last 12 months)',
'Total Cash Withdrawal (In last 12 months) Amount'
];
const rows = [headers];
const rows: string[][] = [];
// Build one aggregate row per (reportRefNo, accountNumber) pair
const seen = new Set<string>();
for (const t of this.filteredCtr) {
@@ -1105,12 +1230,11 @@ export class FiuComponent implements OnInit {
.toString()
]);
}
this.saveCsv(rows, 'Account_Detail.csv');
this.toast(`Account_Detail exported (${rows.length - 1} accounts)`);
return { headers, rows };
}
/** Account_Person_Relation.csv — account ↔ customer mapping */
exportAccountPersonRelation(): void {
/** Account_Person_Relation — account ↔ customer mapping */
private buildAccountPersonRelation(): FinnetTable {
const headers = [
'Report Type',
'Report Reference Number',
@@ -1121,7 +1245,7 @@ export class FiuComponent implements OnInit {
'Individual / Non-individual',
'Name of Non-customer'
];
const rows = [headers];
const rows: string[][] = [];
const seen = new Set<string>();
for (const t of this.filteredCtr) {
const key = `${t.reportRefNo}|${t.accountNumber}`;
@@ -1140,8 +1264,145 @@ export class FiuComponent implements OnInit {
t.relationshipFlag === 'Non-Customer' ? t.customerName || '' : ''
]);
}
this.saveCsv(rows, 'Account_Person_Relation.csv');
this.toast(`Account_Person_Relation exported (${rows.length - 1} rows)`);
return { headers, rows };
}
// -------------------- FINnet file sequence --------------------
/**
* The six CTR files in the order FIU-IND expects them: the account is
* described first, then who is behind it, then the transactions, and finally
* the customer cards those transactions belong to.
*/
private get allFinnetFiles(): FinnetFile[] {
return [
{
key: 'account-detail',
name: 'Account_Detail',
step: 1,
title: 'Account Detail',
purpose:
'One row per reported account — status, opening date and the 12-month debit, credit and cash aggregates.',
table: this.buildAccountDetail()
},
{
key: 'account-person-relation',
name: 'Account_Person_Relation',
step: 2,
title: 'Account ↔ Person Relation',
purpose:
'Links each account to the customers behind it, with the relationship type and whether they are an individual or an entity.',
table: this.buildAccountPersonRelation()
},
{
key: 'tc1',
name: 'TC1',
step: 3,
title: 'TC1 — ATM / Card Transactions',
purpose:
'The card-rail leg of the same CTR: ATM withdrawals, recycler deposits and declined attempts. An ATM cash withdrawal is still cash, so it counts towards the customer’s monthly aggregate alongside TC2.',
table: this.buildTc1()
},
{
key: 'tc2',
name: 'TC2',
step: 4,
title: 'TC2 — Branch / Cash Transactions',
purpose: 'The transaction detail itself — every cash transaction that put the account over the CTR threshold.',
table: this.buildTc2()
},
{
key: 'kc1',
name: 'KC1',
step: 5,
title: 'KC1 — Individual Customer Cards',
purpose: 'Full KYC card for every individual named in the report: identifiers, address, income, risk level.',
table: this.buildKc1()
},
{
key: 'kc2',
name: 'KC2',
step: 6,
title: 'KC2 — Entity Customer Cards',
purpose: 'The same for non-individuals: firms, HUFs and societies, with GSTIN and sole-proprietor ownership.',
table: this.buildKc2()
}
];
}
/**
* The files walked through in the CTR tab: account, then who is behind it,
* then the two transaction files. The customer cards are excluded here and
* live on the Customer Cards tab instead, though they still travel with the
* full bundle.
*/
get finnetSequence(): FinnetFile[] {
return this.allFinnetFiles.filter((f) => f.key !== 'kc1' && f.key !== 'kc2');
}
/** Which file in the sequence is open in the CTR tab. */
activeFinnetFile = 'account-detail';
get openFinnetFile(): FinnetFile {
return this.finnetSequence.find((f) => f.key === this.activeFinnetFile) ?? this.finnetSequence[0];
}
showFinnetFile(key: string): void {
this.activeFinnetFile = key;
}
/** Downloads whichever file is currently previewed, so the button matches the table. */
downloadOpenFinnetFile(): void {
const f = this.openFinnetFile;
this.saveCsv(
[
f.table.headers,
...f.table.rows
],
`${f.name}.csv`
);
this.toast(`${f.name} exported (${f.table.rows.length} rows)`);
}
// -------------------- Exports --------------------
private saveTable(file: FinnetFile): void {
this.saveCsv(
[
file.table.headers,
...file.table.rows
],
`${file.name}.csv`
);
this.toast(`${file.name} exported (${file.table.rows.length} rows)`);
}
private fileByKey(key: string): FinnetFile {
return this.allFinnetFiles.find((f) => f.key === key)!;
}
exportTc1(): void {
this.saveTable(this.fileByKey('tc1'));
}
exportTc2(): void {
this.saveTable(this.fileByKey('tc2'));
}
exportKc1(): void {
this.saveTable(this.fileByKey('kc1'));
}
exportKc2(): void {
this.saveTable(this.fileByKey('kc2'));
}
exportAccountDetail(): void {
this.saveTable(this.fileByKey('account-detail'));
}
exportAccountPersonRelation(): void {
this.saveTable(this.fileByKey('account-person-relation'));
}
/** STR XML stub (FINnet 2.0 STR XML) */
@@ -1187,15 +1448,16 @@ export class FiuComponent implements OnInit {
URL.revokeObjectURL(url);
}
/** Bundle: the 6 CSVs plus the STR XML — emitted as separate downloads sequentially */
/**
* Bundle: the 6 CSVs plus the STR XML, emitted as separate downloads in the
* order FIU-IND expects them — account, then relation, then TC, then KC.
*/
exportAll(): void {
setTimeout(() => this.exportTc1(), 0);
setTimeout(() => this.exportTc2(), 200);
setTimeout(() => this.exportKc1(), 400);
setTimeout(() => this.exportKc2(), 600);
setTimeout(() => this.exportAccountDetail(), 800);
setTimeout(() => this.exportAccountPersonRelation(), 1000);
setTimeout(() => this.exportStrXml(), 1200);
// All six travel together — the customer cards are not walked through in the
// CTR tab, but a bundle without them would be incomplete.
const files = this.allFinnetFiles;
files.forEach((f, i) => setTimeout(() => this.saveTable(f), i * 200));
setTimeout(() => this.exportStrXml(), files.length * 200);
}
private escapeXml(s: string): string {
@@ -53,6 +53,12 @@ const routes: Routes = [
(m) => m.FormDeclarationReportComponent
)
},
{
path: 'risk-review',
data: { title: 'Risk Review Report', breadcrumb: 'Risk Review Report' },
loadComponent: () =>
import('../risk-review/risk-review-report.component').then((m) => m.RiskReviewReportComponent)
},
{
path: ':filter',
data: { routeParamBreadcrumb: 'filter' },
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,
faFileWord,
faFillDrip,
faGavel,
faGlobe,
faHandHoldingUsd,
faHome,
@@ -297,6 +298,7 @@ export class IconsModule {
faFileExcel,
faFileExport,
faFillDrip,
faGavel,
faGlobe,
faHandHoldingHand,
faHandHoldingHeart,
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260801',
'hash': '2a9df828'
'version': '260802',
'hash': '0ca6842b'
},
'allow_switching_backend_instance': true
};