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
+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 {