1495 lines
50 KiB
TypeScript
1495 lines
50 KiB
TypeScript
/**
|
||
* 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 { Component, OnInit, inject } from '@angular/core';
|
||
import { FormsModule } from '@angular/forms';
|
||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||
import { AmlService } from 'app/aml/aml.service';
|
||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||
import {
|
||
BRANCHES,
|
||
BranchMaster,
|
||
CardTransaction,
|
||
CtrTransaction,
|
||
CTR_TXNS,
|
||
TC1_TXNS,
|
||
FIU_BANK,
|
||
FIU_BRANCH_CODE_TO_CANONICAL,
|
||
KC1_INDIVIDUALS,
|
||
KC2_ENTITIES,
|
||
KycEntity,
|
||
KycIndividual,
|
||
PoFinding,
|
||
StrStatus,
|
||
StrTransaction,
|
||
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',
|
||
styleUrls: ['./fiu.component.scss'],
|
||
imports: [
|
||
...STANDALONE_SHARED_IMPORTS,
|
||
FaIconComponent,
|
||
FormsModule
|
||
]
|
||
})
|
||
export class FiuComponent implements OnInit {
|
||
/** Bump when CTR_TXNS / STR_TXNS change so cached rows are re-seeded. */
|
||
private static readonly SEED_VERSION = '2026-01-cash-threshold';
|
||
|
||
private amlService = inject(AmlService);
|
||
private authenticationService = inject(AuthenticationService);
|
||
/** 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;
|
||
|
||
// -------------------- Head Office vs Branch scope --------------------
|
||
// CTR/STR filing itself is a bank-wide Principal-Officer function (one filing for the whole
|
||
// bank, not per branch), but the underlying cash transactions a branch's own staff work
|
||
// (evidence capture, referral) are still branch-attributable — scoped the same way every other
|
||
// report in the app splits Head Office (sees everything, can drill into one branch) vs a
|
||
// branch-level login (locked to its own branch).
|
||
readonly branches = BRANCHES;
|
||
isHeadOffice = false;
|
||
currentOfficeName = 'Branch Office';
|
||
private ownBranchCode: string | null = null;
|
||
/** '' = All Branches (Head Office consolidated view); otherwise a BranchMaster.code. */
|
||
branchFilter = '';
|
||
|
||
/** CTR filters */
|
||
ctrFilterMode: '' | 'Deposit' | 'Withdrawal' = '';
|
||
ctrFilterAccount = '';
|
||
ctrFilterMinAmount = 0;
|
||
|
||
/** STR filters */
|
||
strFilterStatus: '' | StrStatus = '';
|
||
strFilterRisk: string = '';
|
||
|
||
/** Ref of the STR card to highlight after jumping across from the CTR tab */
|
||
highlightedStrRef: number | null = null;
|
||
|
||
/** Toast */
|
||
toastMsg = '';
|
||
toastVisible = false;
|
||
private toastTimer: any;
|
||
|
||
// -------------------- Live AML monitoring (real backend, replaces mock CTR/STR tables) --------------------
|
||
amlAlerts: any[] = [];
|
||
amlAlertStatusFilter = '';
|
||
amlReviewNotes: Record<number, string> = {};
|
||
|
||
amlCtrReports: any[] = [];
|
||
ctrGenerateMonth = '';
|
||
|
||
amlStrCases: any[] = [];
|
||
selectedStrCase: any = null;
|
||
concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS';
|
||
concludeInvestigationNotes = '';
|
||
concludeSourceOfFundsNotes = '';
|
||
fileReferenceNo = '';
|
||
|
||
/**
|
||
* The Principal Officer's verification checklist. Each point is answered
|
||
* "Genuine" or "Not satisfied"; the case conclusion is derived from the answers
|
||
* rather than picked by hand, so a case with an unresolved finding cannot be
|
||
* whitelisted by accident.
|
||
*/
|
||
readonly strChecklist: { key: string; label: string; hint: string }[] = [
|
||
{
|
||
key: 'sourceOfFunds',
|
||
label: 'Source of funds established and documented',
|
||
hint: 'Salary slips, sale deed, business receipts or other credible evidence on file'
|
||
},
|
||
{
|
||
key: 'profileConsistent',
|
||
label: 'Transaction consistent with the customer’s declared profile',
|
||
hint: 'Value and frequency in line with declared income, occupation and turnover'
|
||
},
|
||
{
|
||
key: 'kycCurrent',
|
||
label: 'KYC records complete and within re-KYC validity',
|
||
hint: 'Identity, address and beneficial-ownership records verified and current'
|
||
},
|
||
{
|
||
key: 'screeningClear',
|
||
label: 'Sanctions, PEP and adverse-media screening clear',
|
||
hint: 'No UNSCC / MHA listing, no PEP linkage, no negative news on the customer'
|
||
},
|
||
{
|
||
key: 'patternConsistent',
|
||
label: 'Pattern consistent with the account’s own history',
|
||
hint: 'No structuring, sudden dormancy revival or rapid pass-through observed'
|
||
},
|
||
{
|
||
key: 'counterpartyKnown',
|
||
label: 'Counterparty identifiable and legitimate',
|
||
hint: 'Remitter / beneficiary traceable and holding a plausible commercial relationship'
|
||
},
|
||
{
|
||
key: 'documentsGenuine',
|
||
label: 'Supporting documents verified as genuine',
|
||
hint: 'Invoices, contracts and bank statements independently corroborated'
|
||
},
|
||
{
|
||
key: 'noLawEnforcement',
|
||
label: 'No law-enforcement or regulatory reference pending',
|
||
hint: 'No summons, freeze order or LEA request outstanding against the account'
|
||
}
|
||
];
|
||
|
||
/** null = not yet answered, true = genuine, false = not satisfied. */
|
||
strFindings: Record<string, boolean | null> = {};
|
||
|
||
// ── Investigation of a CTR-referred STR case ───────────────────────────────
|
||
/** The local STR card currently under investigation, if any. */
|
||
investigatingStr: StrTransaction | null = null;
|
||
|
||
/** Documents the Principal Officer can call for to evidence the source of funds. */
|
||
readonly evidenceDocTypes: string[] = [
|
||
'Sale deed / agreement for immovable property',
|
||
'Salary slips and Form 16',
|
||
'Audited financials / ITR of the business',
|
||
'Loan sanction or disbursement letter',
|
||
'Gift deed with donor PAN and relationship proof',
|
||
'Maturity advice of a deposit or insurance policy',
|
||
'Sale bill for gold, vehicle or other asset',
|
||
'Agricultural income certificate / mandi receipts',
|
||
'No document produced by the customer'
|
||
];
|
||
|
||
evidenceDocType = '';
|
||
evidenceReceived: 'YES' | 'PARTIAL' | 'NO' | '' = '';
|
||
evidenceReference = '';
|
||
customerExplanation = '';
|
||
|
||
/** Opens the investigation panel for a CTR-referred case with a clean slate. */
|
||
investigateLocalStr(rec: StrTransaction): void {
|
||
this.investigatingStr = rec;
|
||
this.selectedStrCase = null;
|
||
this.resetChecklist();
|
||
this.evidenceDocType = '';
|
||
this.evidenceReceived = '';
|
||
this.evidenceReference = '';
|
||
this.customerExplanation = '';
|
||
this.concludeInvestigationNotes = '';
|
||
this.concludeSourceOfFundsNotes = '';
|
||
}
|
||
|
||
closeLocalStrInvestigation(): void {
|
||
this.investigatingStr = null;
|
||
}
|
||
|
||
/** Documentary evidence is a precondition for clearing a case, not an optional note. */
|
||
get evidenceRecorded(): boolean {
|
||
return !!this.evidenceDocType && !!this.evidenceReceived;
|
||
}
|
||
|
||
get canConcludeLocal(): boolean {
|
||
return this.evidenceRecorded && this.allAnswered && !!this.customerExplanation.trim();
|
||
}
|
||
|
||
private resetChecklist(): void {
|
||
this.strFindings = {};
|
||
for (const item of this.strChecklist) {
|
||
this.strFindings[item.key] = null;
|
||
}
|
||
}
|
||
|
||
setFinding(key: string, genuine: boolean): void {
|
||
this.strFindings[key] = genuine;
|
||
this.concludeConclusion = this.derivedConclusion;
|
||
}
|
||
|
||
get answeredCount(): number {
|
||
return this.strChecklist.filter((i) => this.strFindings[i.key] !== null).length;
|
||
}
|
||
get allAnswered(): boolean {
|
||
return this.answeredCount === this.strChecklist.length;
|
||
}
|
||
/** Points the officer marked as not satisfied — these are what force an STR. */
|
||
get failedFindings(): { key: string; label: string; hint: string }[] {
|
||
return this.strChecklist.filter((i) => this.strFindings[i.key] === false);
|
||
}
|
||
|
||
/**
|
||
* Everything genuine means the case is cleared; a single unsatisfied point
|
||
* means it must be filed as suspicious.
|
||
*/
|
||
get derivedConclusion(): 'NOT_SUSPICIOUS' | 'SUSPICIOUS' {
|
||
return this.failedFindings.length > 0 ? 'SUSPICIOUS' : 'NOT_SUSPICIOUS';
|
||
}
|
||
|
||
get isPrincipalOfficer(): boolean {
|
||
return this.authenticationService.isPrincipalOfficerUser();
|
||
}
|
||
|
||
get isAmlOfficer(): boolean {
|
||
return this.authenticationService.isAmlOfficerUser();
|
||
}
|
||
|
||
ngOnInit(): void {
|
||
const creds = this.authenticationService.getCredentials();
|
||
this.isHeadOffice = this.authenticationService.isHeadOfficeUser();
|
||
this.currentOfficeName = creds?.officeName || 'Branch Office';
|
||
if (this.isHeadOffice) {
|
||
this.branchFilter = '';
|
||
} else {
|
||
this.ownBranchCode =
|
||
this.branches.find((b: BranchMaster) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))
|
||
?.code || null;
|
||
this.branchFilter = this.ownBranchCode || this.branches[0].code;
|
||
}
|
||
|
||
this.loadAmlAlerts();
|
||
this.loadAmlCtrReports();
|
||
this.loadAmlStrCases();
|
||
|
||
// Bumping SEED_VERSION discards previously cached rows so a new data set takes effect.
|
||
if (localStorage.getItem('fiu_seed_version') !== FiuComponent.SEED_VERSION) {
|
||
localStorage.setItem('fiu_seed_version', FiuComponent.SEED_VERSION);
|
||
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
|
||
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
|
||
return;
|
||
}
|
||
|
||
const storedCtr = localStorage.getItem('fiu_ctr_txns');
|
||
if (storedCtr) {
|
||
this.ctrTxns = JSON.parse(storedCtr);
|
||
} else {
|
||
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
|
||
}
|
||
|
||
const storedStr = localStorage.getItem('fiu_str_cases');
|
||
if (storedStr) {
|
||
this.strTxns = JSON.parse(storedStr);
|
||
} else {
|
||
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
|
||
}
|
||
}
|
||
|
||
// ── CTR-side evidence capture ──────────────────────────────────────────────
|
||
/** Transaction whose source-of-funds evidence is being recorded, if any. */
|
||
evidencingTxn: CtrTransaction | null = null;
|
||
ctrEvidenceDocType = '';
|
||
ctrEvidenceRef = '';
|
||
|
||
startCtrEvidence(txn: CtrTransaction): void {
|
||
this.evidencingTxn = txn;
|
||
this.ctrEvidenceDocType = '';
|
||
this.ctrEvidenceRef = '';
|
||
}
|
||
|
||
cancelCtrEvidence(): void {
|
||
this.evidencingTxn = null;
|
||
}
|
||
|
||
/**
|
||
* Records that the source of funds was evidenced at the counter. This is a
|
||
* documentation step, not a suspicion verdict — the transaction is reported in
|
||
* the CTR either way; recording evidence simply means no STR referral is needed.
|
||
*/
|
||
confirmCtrEvidence(): void {
|
||
const txn = this.evidencingTxn;
|
||
if (!txn) {
|
||
return;
|
||
}
|
||
if (!this.ctrEvidenceDocType) {
|
||
this.toast('Select the document that evidences the source of funds');
|
||
return;
|
||
}
|
||
txn.complianceStatus = 'EVIDENCED';
|
||
txn.evidenceDocType = this.ctrEvidenceDocType;
|
||
txn.evidenceReference = this.ctrEvidenceRef.trim();
|
||
txn.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
|
||
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
|
||
this.evidencingTxn = null;
|
||
this.toast(`${txn.customerName} — evidence recorded. Reported in CTR only, no STR referral.`);
|
||
}
|
||
|
||
/**
|
||
* Referral to STR. Used when the source of funds could not be evidenced; the
|
||
* Principal Officer then investigates and decides whether to file with FIU-IND.
|
||
*/
|
||
referCtrTxnToStr(txn: CtrTransaction): void {
|
||
txn.complianceStatus = 'REFERRED';
|
||
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
|
||
|
||
const newRef = Math.max(100, ...this.strTxns.map((s) => s.reportRefNo)) + 1;
|
||
const newStrCase: StrTransaction = {
|
||
...txn,
|
||
reportType: 'STR',
|
||
reportRefNo: newRef,
|
||
detectedOn: new Date().toLocaleDateString('en-GB').replace(/\//g, '-'),
|
||
flaggedBy: 'AML Officer',
|
||
riskCategory: 'unusual-pattern',
|
||
narrative:
|
||
`Cash ${txn.depositOrWithdrawal.toLowerCase()} of ₹${txn.txnAmount.toLocaleString('en-IN')} breached the ` +
|
||
`₹${this.bank.cashThreshold.toLocaleString('en-IN')} CTR limit and genuine evidence of the source of funds ` +
|
||
`could not be established at the branch. Referred to the Principal Officer for investigation.`,
|
||
status: 'po-review',
|
||
poFinding: undefined
|
||
};
|
||
|
||
this.strTxns.unshift(newStrCase);
|
||
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
|
||
this.toast(`${txn.customerName} referred to STR-${newRef} — awaiting Principal Officer investigation`);
|
||
}
|
||
|
||
/**
|
||
* Concludes a CTR-referred case. The verdict follows the evidence and the
|
||
* verification checklist rather than a free choice, and the full trail is written
|
||
* into the case remarks so the filing carries its own justification.
|
||
*/
|
||
concludeLocalStr(): void {
|
||
const rec = this.investigatingStr;
|
||
if (!rec) {
|
||
return;
|
||
}
|
||
if (!this.evidenceRecorded) {
|
||
this.toast('Record which document was called for and whether it was produced');
|
||
return;
|
||
}
|
||
if (!this.customerExplanation.trim()) {
|
||
this.toast('Record the explanation the customer gave for the source of funds');
|
||
return;
|
||
}
|
||
if (!this.allAnswered) {
|
||
this.toast(`Answer all verification points — ${this.strChecklist.length - this.answeredCount} still pending`);
|
||
return;
|
||
}
|
||
|
||
const finding: PoFinding = this.derivedConclusion === 'SUSPICIOUS' ? 'SUSPICIOUS' : 'CLEAN';
|
||
const failed = this.failedFindings;
|
||
const evidenceLine =
|
||
`Document called for: ${this.evidenceDocType}. Produced: ${this.evidenceReceived}` +
|
||
(this.evidenceReference.trim() ? ` (ref ${this.evidenceReference.trim()})` : '') +
|
||
'.';
|
||
const explanationLine = `Customer's explanation: ${this.customerExplanation.trim()}`;
|
||
|
||
rec.poFinding = finding;
|
||
if (finding === 'CLEAN') {
|
||
rec.status = 'rejected';
|
||
rec.poRemarks =
|
||
`${evidenceLine} ${explanationLine} All ${this.strChecklist.length} verification points found genuine, ` +
|
||
`so the source of funds is established. Case closed — the transaction remains in the CTR report and no STR ` +
|
||
`is filed with FIU-IND.`;
|
||
// The originating CTR row must stop advertising a live STR once cleared.
|
||
const origin = this.ctrTxns.find((t) => t.txnId === rec.txnId);
|
||
if (origin) {
|
||
origin.complianceStatus = 'EVIDENCED';
|
||
origin.evidenceDocType = this.evidenceDocType;
|
||
origin.evidenceReference = this.evidenceReference.trim();
|
||
origin.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
|
||
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
|
||
}
|
||
this.toast(`STR-${rec.reportRefNo} closed as clean — CTR only, no STR filed`);
|
||
} else {
|
||
// Concluding suspicious does not file anything — the STR still has to be
|
||
// submitted on the FINnet portal and its acknowledgement recorded.
|
||
rec.status = 'awaiting-filing';
|
||
rec.poRemarks =
|
||
`${evidenceLine} ${explanationLine} ${failed.length} of ${this.strChecklist.length} verification points not ` +
|
||
`satisfied: ${failed.map((f) => f.label).join('; ')}. Source of funds not established — STR to be filed with ` +
|
||
`FIU-IND under PMLA s.12 within 7 days and the account placed under enhanced monitoring.`;
|
||
this.toast(`STR-${rec.reportRefNo} concluded suspicious — now awaiting submission to FIU-IND`);
|
||
}
|
||
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
|
||
this.investigatingStr = null;
|
||
}
|
||
|
||
/** Acknowledgement number captured against a case awaiting submission. */
|
||
localFileRefs: Record<number, string> = {};
|
||
|
||
/**
|
||
* Records that the STR was actually submitted on the FINnet portal. Only this
|
||
* step moves a case to "filed" and counts it in the filed KPI.
|
||
*/
|
||
fileLocalStr(rec: StrTransaction): void {
|
||
const ref = (this.localFileRefs[rec.reportRefNo] || '').trim();
|
||
if (!ref) {
|
||
this.toast('Enter the FIU-IND acknowledgement number received on the FINnet portal');
|
||
return;
|
||
}
|
||
rec.status = 'filed';
|
||
rec.fiuReferenceNo = ref;
|
||
rec.filedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
|
||
rec.poRemarks = `${rec.poRemarks ?? ''} Submitted to FIU-IND on ${rec.filedOn}; acknowledgement ${ref}.`.trim();
|
||
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
|
||
this.toast(`STR-${rec.reportRefNo} filed with FIU-IND — acknowledgement ${ref}`);
|
||
}
|
||
|
||
/** Generates the FINnet STR XML for one case, ready to upload to the portal. */
|
||
exportSingleStrXml(rec: StrTransaction): void {
|
||
const saved = this.strFilterStatus;
|
||
const savedRisk = this.strFilterRisk;
|
||
// Temporarily narrow the export to this case only, then restore the filters.
|
||
this.strFilterStatus = '';
|
||
this.strFilterRisk = '';
|
||
const only = [rec];
|
||
this.writeStrXml(only, `STR-${rec.reportRefNo}-${rec.customerName ?? 'case'}.xml`);
|
||
this.strFilterStatus = saved;
|
||
this.strFilterRisk = savedRisk;
|
||
this.toast(`STR-${rec.reportRefNo} XML generated — upload it on the FINnet portal`);
|
||
}
|
||
|
||
poFindingLabel(finding: PoFinding): string {
|
||
return finding === 'CLEAN'
|
||
? 'Clean — source of funds verified as legitimate. No STR filed.'
|
||
: 'Suspicious — STR filed with FIU-IND and account escalated.';
|
||
}
|
||
|
||
loadAmlAlerts(): void {
|
||
this.amlService
|
||
.getAlerts(this.amlAlertStatusFilter || undefined)
|
||
.subscribe((data) => (this.amlAlerts = data || []));
|
||
}
|
||
|
||
loadAmlCtrReports(): void {
|
||
this.amlService.getCtrReports().subscribe((data) => (this.amlCtrReports = data || []));
|
||
}
|
||
|
||
loadAmlStrCases(): void {
|
||
this.amlService.getStrCases().subscribe((data) => (this.amlStrCases = data || []));
|
||
}
|
||
|
||
/** Surfaces a failed backend call — without this a rejected request looks like a dead button. */
|
||
private apiError(action: string) {
|
||
return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`);
|
||
}
|
||
|
||
whitelistAlert(alert: any): void {
|
||
this.amlService.reviewAlert(alert.id, 'WHITELIST', this.amlReviewNotes[alert.id] || '').subscribe({
|
||
next: () => {
|
||
this.toast(`Alert #${alert.id} whitelisted — no STR raised`);
|
||
this.loadAmlAlerts();
|
||
},
|
||
error: this.apiError(`Whitelisting alert #${alert.id}`)
|
||
});
|
||
}
|
||
|
||
escalateAlert(alert: any): void {
|
||
this.amlService.reviewAlert(alert.id, 'ESCALATE', this.amlReviewNotes[alert.id] || '').subscribe({
|
||
next: () => {
|
||
this.toast(`Alert #${alert.id} escalated — now an STR case for the Principal Officer`);
|
||
this.loadAmlAlerts();
|
||
this.loadAmlStrCases();
|
||
},
|
||
error: this.apiError(`Escalating alert #${alert.id}`)
|
||
});
|
||
}
|
||
|
||
generateCtrReport(): void {
|
||
if (!this.ctrGenerateMonth) {
|
||
this.toast('Pick a reporting month first');
|
||
return;
|
||
}
|
||
this.amlService.generateCtrReport(this.ctrGenerateMonth).subscribe({
|
||
next: () => {
|
||
this.toast(`CTR report generated for ${this.ctrGenerateMonth}`);
|
||
this.loadAmlCtrReports();
|
||
},
|
||
error: this.apiError('CTR report generation')
|
||
});
|
||
}
|
||
|
||
selectStrCase(strCase: any): void {
|
||
this.selectedStrCase = strCase;
|
||
// Preserve an existing verdict instead of silently resetting it to NOT_SUSPICIOUS.
|
||
this.concludeConclusion = strCase.conclusion === 'SUSPICIOUS' ? 'SUSPICIOUS' : 'NOT_SUSPICIOUS';
|
||
this.concludeInvestigationNotes = strCase.investigationNotes || '';
|
||
this.concludeSourceOfFundsNotes = strCase.sourceOfFundsNotes || '';
|
||
this.fileReferenceNo = strCase.strReferenceNo || '';
|
||
this.resetChecklist();
|
||
}
|
||
|
||
/** Plain-language outcome for a live STR case, shown in the investigation panel. */
|
||
strCaseOutcome(strCase: any): string {
|
||
switch (strCase?.status) {
|
||
case 'CONCLUDED_CLEAR':
|
||
return 'Concluded not suspicious — case closed, nothing filed with FIU-IND.';
|
||
case 'CONCLUDED_SUSPICIOUS':
|
||
return `Concluded suspicious — STR must be filed with FIU-IND by ${strCase.fiuDueDate || 'the due date'}.`;
|
||
case 'FILED':
|
||
return `STR filed with FIU-IND${strCase.strReferenceNo ? ` under reference ${strCase.strReferenceNo}` : ''}.`;
|
||
default:
|
||
return 'Open — awaiting the Principal Officer’s conclusion.';
|
||
}
|
||
}
|
||
|
||
closeStrCaseDetail(): void {
|
||
this.selectedStrCase = null;
|
||
}
|
||
|
||
concludeStrCase(): void {
|
||
if (!this.selectedStrCase) {
|
||
return;
|
||
}
|
||
if (!this.allAnswered) {
|
||
const pending = this.strChecklist.length - this.answeredCount;
|
||
this.toast(`Answer all verification points first — ${pending} still pending`);
|
||
return;
|
||
}
|
||
if (!this.concludeInvestigationNotes.trim()) {
|
||
this.toast('Record your investigation notes before concluding');
|
||
return;
|
||
}
|
||
// The verdict always follows the checklist, never a stale radio selection.
|
||
this.concludeConclusion = this.derivedConclusion;
|
||
// Fold the unsatisfied points into the notes so the STR carries its own reasons.
|
||
const failed = this.failedFindings;
|
||
const notes = failed.length
|
||
? `${this.concludeInvestigationNotes.trim()}\n\nUnsatisfied verification points:\n` +
|
||
failed.map((f) => `• ${f.label}`).join('\n')
|
||
: `${this.concludeInvestigationNotes.trim()}\n\nAll ${this.strChecklist.length} verification points found genuine.`;
|
||
|
||
this.amlService
|
||
.concludeStrCase(this.selectedStrCase.id, this.concludeConclusion, notes, this.concludeSourceOfFundsNotes)
|
||
.subscribe({
|
||
next: (updated) => {
|
||
this.toast(
|
||
this.concludeConclusion === 'SUSPICIOUS'
|
||
? `STR case #${updated.id} concluded suspicious — file with FIU-IND by ${updated.fiuDueDate}`
|
||
: `STR case #${updated.id} concluded not suspicious — no filing required`
|
||
);
|
||
this.selectedStrCase = updated;
|
||
this.loadAmlStrCases();
|
||
},
|
||
error: this.apiError(`Concluding STR case #${this.selectedStrCase.id}`)
|
||
});
|
||
}
|
||
|
||
fileStrCase(): void {
|
||
if (!this.selectedStrCase) {
|
||
return;
|
||
}
|
||
if (!this.fileReferenceNo.trim()) {
|
||
this.toast('Enter the FIU-IND acknowledgement number first');
|
||
return;
|
||
}
|
||
this.amlService.fileStr(this.selectedStrCase.id, this.fileReferenceNo).subscribe({
|
||
next: (updated) => {
|
||
this.toast(`STR case #${updated.id} recorded as filed (ref ${updated.strReferenceNo})`);
|
||
this.selectedStrCase = updated;
|
||
this.loadAmlStrCases();
|
||
},
|
||
error: this.apiError(`Filing STR case #${this.selectedStrCase.id}`)
|
||
});
|
||
}
|
||
|
||
amlStatusClass(status: string): string {
|
||
return 'status-pill status-' + (status || '').toLowerCase().replace(/_/g, '-');
|
||
}
|
||
|
||
/**
|
||
* Where a live AML alert stands — drives the "Routes To" column.
|
||
* Whitelisting is an STR-side decision, so a CTR alert can only be pending or
|
||
* referred onward. Historic WHITELISTED rows are still rendered for the record.
|
||
*/
|
||
alertRouteLabel(status: string, isCtrRule: boolean = true): string {
|
||
switch (status) {
|
||
case 'WHITELISTED':
|
||
return isCtrRule ? 'CTR only' : 'Cleared';
|
||
case 'ESCALATED':
|
||
return isCtrRule ? 'Referred to STR' : '→ STR';
|
||
default:
|
||
return 'Pending';
|
||
}
|
||
}
|
||
|
||
alertRouteClass(status: string, isCtrRule: boolean = true): string {
|
||
switch (status) {
|
||
case 'WHITELISTED':
|
||
return isCtrRule ? 'report-pill report-ctr' : 'report-pill report-cleared';
|
||
case 'ESCALATED':
|
||
return 'report-pill report-str';
|
||
default:
|
||
return 'report-pill report-pending';
|
||
}
|
||
}
|
||
|
||
showTab(tab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files'): void {
|
||
this.activeTab = tab;
|
||
}
|
||
|
||
/**
|
||
* Jump from a flagged CTR row to its STR card. Clears any status/risk filter that
|
||
* would hide the case, then scrolls it into view and highlights it.
|
||
*/
|
||
openStrCase(txn: CtrTransaction): void {
|
||
const match = this.strTxns.find((s) => s.txnId === txn.txnId);
|
||
if (!match) {
|
||
this.toast('No STR case found for this transaction');
|
||
return;
|
||
}
|
||
this.activeTab = 'str';
|
||
this.resetStrFilters();
|
||
this.highlightedStrRef = match.reportRefNo;
|
||
setTimeout(() => {
|
||
document.getElementById(`str-card-${match.reportRefNo}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
});
|
||
}
|
||
|
||
// -------------------- KPIs --------------------
|
||
get totalReports(): number {
|
||
return new Set(this.scopedCtrTxns.map((t) => t.reportRefNo)).size;
|
||
}
|
||
get totalCtrTxns(): number {
|
||
return this.scopedCtrTxns.length;
|
||
}
|
||
get totalCtrAmount(): number {
|
||
return this.scopedCtrTxns.reduce((s, t) => s + t.txnAmount, 0);
|
||
}
|
||
get pendingStr(): number {
|
||
return this.scopedStrTxns.filter(
|
||
(s) => s.status === 'draft' || s.status === 'po-review' || s.status === 'awaiting-filing'
|
||
).length;
|
||
}
|
||
get filedStr(): number {
|
||
return this.scopedStrTxns.filter((s) => s.status === 'filed').length;
|
||
}
|
||
|
||
// ── Live STR case counts (backend `amlStrCases`) — these are the cases the
|
||
// Principal Officer actually works through the Investigate → Conclude → File flow.
|
||
get liveStrOpen(): number {
|
||
return this.amlStrCases.filter((c) => c.status === 'OPEN').length;
|
||
}
|
||
get liveStrAwaitingFiling(): number {
|
||
return this.amlStrCases.filter((c) => c.status === 'CONCLUDED_SUSPICIOUS').length;
|
||
}
|
||
get liveStrCleared(): number {
|
||
return this.amlStrCases.filter((c) => c.status === 'CONCLUDED_CLEAR').length;
|
||
}
|
||
get liveStrFiled(): number {
|
||
return this.amlStrCases.filter((c) => c.status === 'FILED').length;
|
||
}
|
||
/** Still needs PO action: untouched, or concluded suspicious but not yet filed with FIU-IND. */
|
||
get liveStrPending(): number {
|
||
return this.liveStrOpen + this.liveStrAwaitingFiling;
|
||
}
|
||
get totalCustomers(): number {
|
||
return this.kc1.length + this.kc2.length;
|
||
}
|
||
|
||
get ctrAlerts(): any[] {
|
||
const ctrRuleCodes = [
|
||
'CTR_THRESHOLD_10L',
|
||
'STRUCTURED_CTR_10L_MONTHLY',
|
||
'LARGE_CASH_TXN_25L'
|
||
];
|
||
return this.amlAlerts.filter((a) => ctrRuleCodes.includes(a.ruleCode));
|
||
}
|
||
|
||
get amlSuspiciousAlerts(): any[] {
|
||
const ctrRuleCodes = [
|
||
'CTR_THRESHOLD_10L',
|
||
'STRUCTURED_CTR_10L_MONTHLY',
|
||
'LARGE_CASH_TXN_25L'
|
||
];
|
||
return this.amlAlerts.filter((a) => !ctrRuleCodes.includes(a.ruleCode));
|
||
}
|
||
|
||
// -------------------- Head Office vs Branch scope --------------------
|
||
/** Maps a FINnet-style internal branch code to the app-wide canonical branch code. */
|
||
private canonicalBranchOf(branchCode: string): string | undefined {
|
||
return FIU_BRANCH_CODE_TO_CANONICAL[branchCode];
|
||
}
|
||
|
||
/** Branch name for a row's raw FINnet branch code, for display alongside the code itself. */
|
||
branchNameFor(branchCode: string): string {
|
||
const canonical = this.canonicalBranchOf(branchCode);
|
||
return this.branches.find((b) => b.code === canonical)?.name || '';
|
||
}
|
||
|
||
setBranch(code: string): void {
|
||
this.branchFilter = code;
|
||
}
|
||
|
||
/** Name of the branch/scope currently being viewed, for display in the header. */
|
||
get scopeName(): string {
|
||
if (!this.isHeadOffice) {
|
||
return this.currentOfficeName;
|
||
}
|
||
return this.branchFilter
|
||
? this.branches.find((b) => b.code === this.branchFilter)?.name || ''
|
||
: 'All Branches (Consolidated)';
|
||
}
|
||
|
||
get scopedCtrTxns(): CtrTransaction[] {
|
||
return this.branchFilter
|
||
? this.ctrTxns.filter((t) => this.canonicalBranchOf(t.branchCode) === this.branchFilter)
|
||
: this.ctrTxns;
|
||
}
|
||
|
||
get scopedStrTxns(): StrTransaction[] {
|
||
return this.branchFilter
|
||
? this.strTxns.filter((s) => this.canonicalBranchOf(s.branchCode) === this.branchFilter)
|
||
: this.strTxns;
|
||
}
|
||
|
||
// -------------------- Filtered lists --------------------
|
||
get filteredCtr(): CtrTransaction[] {
|
||
const acc = this.ctrFilterAccount.trim();
|
||
return this.scopedCtrTxns.filter((t) => {
|
||
if (this.ctrFilterMode && t.depositOrWithdrawal !== this.ctrFilterMode) return false;
|
||
if (acc && !t.accountNumber.includes(acc)) return false;
|
||
if (this.ctrFilterMinAmount > 0 && t.txnAmount < this.ctrFilterMinAmount) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/** 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;
|
||
if (this.strFilterRisk && s.riskCategory !== this.strFilterRisk) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Cases that actually belong in an STR filing. A case the Principal Officer
|
||
* concluded clean is not a suspect and must never reach FIU-IND.
|
||
*/
|
||
get strSuspects(): StrTransaction[] {
|
||
return this.filteredStr.filter((s) => s.poFinding !== 'CLEAN' && s.status !== 'rejected');
|
||
}
|
||
|
||
resetCtrFilters(): void {
|
||
this.ctrFilterMode = '';
|
||
this.ctrFilterAccount = '';
|
||
this.ctrFilterMinAmount = 0;
|
||
}
|
||
resetStrFilters(): void {
|
||
this.strFilterStatus = '';
|
||
this.strFilterRisk = '';
|
||
}
|
||
|
||
// -------------------- Helpers --------------------
|
||
statusLabel(s: StrStatus): string {
|
||
const map: Record<StrStatus, string> = {
|
||
draft: 'Draft',
|
||
'po-review': 'PO Review',
|
||
'awaiting-filing': 'Awaiting Filing',
|
||
filed: 'Filed',
|
||
rejected: 'Closed — Clean'
|
||
};
|
||
return map[s] ?? s;
|
||
}
|
||
riskLabel(c: StrTransaction['riskCategory']): string {
|
||
const map: Record<StrTransaction['riskCategory'], string> = {
|
||
structuring: 'Structuring',
|
||
'dormant-revival': 'Dormant Revival',
|
||
'high-risk-country': 'High-Risk Country',
|
||
'unusual-pattern': 'Unusual Pattern',
|
||
'pep-related': 'PEP Related',
|
||
'sanctions-hit': 'Sanctions Hit'
|
||
};
|
||
return map[c];
|
||
}
|
||
|
||
// -------------------- 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',
|
||
'Branch Code of Account',
|
||
'Account Number',
|
||
'Transaction Branch Code',
|
||
'Deposit/Withdrawal',
|
||
'Transaction ID',
|
||
'Transaction Date',
|
||
'Transaction Time',
|
||
'Transaction Amount',
|
||
'Instrument Type',
|
||
'Instrument ID (Card Number/ Cash/ Cheque/ DD/ Others)',
|
||
'Non-Customer Reference Number',
|
||
'Relationship Flag',
|
||
'Third Party PAN (Non-Customer)',
|
||
'Declaration (If Third Party PAN (Non-Customer) is not available)'
|
||
];
|
||
const rows: string[][] = [];
|
||
for (const t of this.filteredCtr) {
|
||
rows.push([
|
||
t.reportType,
|
||
t.reportRefNo.toString(),
|
||
t.branchCode,
|
||
t.accountNumber,
|
||
t.txnBranchCode,
|
||
t.depositOrWithdrawal,
|
||
t.txnId.toString(),
|
||
t.txnDate.replace(/-/g, '/'),
|
||
t.txnTime,
|
||
t.txnAmount.toString(),
|
||
t.instrumentType,
|
||
t.instrumentId,
|
||
t.nonCustomerRef,
|
||
t.relationshipFlag,
|
||
t.thirdPartyPan,
|
||
t.declaration
|
||
]);
|
||
}
|
||
return { headers, rows };
|
||
}
|
||
|
||
/** TC1 — ATM / card transactions. Header-only: this bank books no card txns. */
|
||
private buildTc1(): FinnetTable {
|
||
const headers = [
|
||
'Relationship Flag',
|
||
'Transaction Status',
|
||
'Transaction ID',
|
||
'Transaction Date',
|
||
'Transaction Time',
|
||
'Deposit/Withdrawal',
|
||
'Transaction Amount',
|
||
'Account Number',
|
||
'Account Type',
|
||
'Card Type',
|
||
'Card Number',
|
||
'Card Issuing Country',
|
||
'Card Issuing Bank',
|
||
'Report Reference Number',
|
||
'Switch Provider Transaction ID',
|
||
'ATM ID/ CAM ID',
|
||
'Branch Code of Account',
|
||
'Report Type'
|
||
];
|
||
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 */
|
||
private buildKc1(): FinnetTable {
|
||
const headers = [
|
||
'Report Reference Number',
|
||
'UCIC',
|
||
'Customer ID',
|
||
'PAN',
|
||
'Declaration (If PAN is not available)',
|
||
'CKYC Number',
|
||
'Declaration (If CKYC is not available)',
|
||
'Passport Number',
|
||
'Voter ID',
|
||
'Other Occupation',
|
||
'Drivers License Number',
|
||
'NREGA Card',
|
||
'DIN / DPIN',
|
||
'First Name',
|
||
'Middle Name',
|
||
'Last Name',
|
||
'Declaration (If Last name is not available)',
|
||
'Name of Father',
|
||
'Name of Mother',
|
||
'Spouse/Partner Name',
|
||
'Gender',
|
||
'Date of Birth',
|
||
'Nationality',
|
||
'Mobile Number',
|
||
'Alternate Mobile Number',
|
||
'Telephone Number',
|
||
'Email ID',
|
||
'Primary Address 1',
|
||
'Primary Address Country',
|
||
'Primary Address Pin Code',
|
||
'Primary Address Locality',
|
||
'Primary Address State',
|
||
'Primary Address District',
|
||
'Primary Address City',
|
||
'Customer Type',
|
||
'Annual Income (INR)',
|
||
'Occupation',
|
||
'Date of Customer On-boarding',
|
||
'Date of Last KYC / re-KYC',
|
||
'Customer Risk Level',
|
||
'Report Type',
|
||
'Other Identifier Name',
|
||
'Other Identifier Number',
|
||
'Primary Entity'
|
||
];
|
||
const rows: string[][] = [];
|
||
for (const k of this.kc1) {
|
||
rows.push([
|
||
k.reportRefNo.toString(),
|
||
k.ucic,
|
||
k.ucic,
|
||
k.pan,
|
||
k.pan ? '' : 'NA',
|
||
k.ckycNumber,
|
||
k.ckycNumber ? '' : 'NA',
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
k.firstName,
|
||
k.middleName,
|
||
k.lastName,
|
||
'',
|
||
k.fatherName,
|
||
k.motherName,
|
||
'',
|
||
k.gender,
|
||
k.dob.replace(/-/g, '/'),
|
||
'97',
|
||
k.mobileNumber,
|
||
'',
|
||
'',
|
||
k.email,
|
||
k.primaryAddress,
|
||
'95',
|
||
k.pincode,
|
||
k.primaryAddress.split(' ').slice(0, 4).join(' '),
|
||
k.state,
|
||
'501',
|
||
k.city,
|
||
k.customerType,
|
||
k.annualIncome.toString(),
|
||
k.occupation,
|
||
k.onboardingDate.replace(/-/g, '/'),
|
||
k.lastKycDate.replace(/-/g, '/'),
|
||
k.riskLevel,
|
||
k.reportType,
|
||
k.otherIdentifierName || '',
|
||
k.otherIdentifierNumber || '',
|
||
'Yes'
|
||
]);
|
||
}
|
||
return { headers, rows };
|
||
}
|
||
|
||
/** KC2 — Entity customers */
|
||
private buildKc2(): FinnetTable {
|
||
const headers = [
|
||
'Report Reference Number',
|
||
'UCIC',
|
||
'Entity Name',
|
||
'Company ID Number',
|
||
'Company ID Type',
|
||
'PAN Number',
|
||
'Declaration (If PAN is not available)',
|
||
'TAN',
|
||
'GSTIN',
|
||
'IEC',
|
||
'Declaration (If IEC is not available)',
|
||
'No identifier available',
|
||
'Telephone Number',
|
||
'Mobile Number',
|
||
'Email ID',
|
||
'Company Website',
|
||
'Country',
|
||
'Address Line 1',
|
||
'PIN Code',
|
||
'Locality',
|
||
'State',
|
||
'District',
|
||
'City / Village / Town',
|
||
'Date Of Incorporation',
|
||
'Customer Type',
|
||
'Last KYC Date',
|
||
'Onboarding Date',
|
||
'Customer Risk Level',
|
||
'Report Type',
|
||
'Sole Proprietor Firm',
|
||
'UCIC of Owner of Sole Proprietor Firm',
|
||
'Name of Owner of Sole Proprietor Firm'
|
||
];
|
||
const rows: string[][] = [];
|
||
for (const k of this.kc2) {
|
||
rows.push([
|
||
k.reportRefNo.toString(),
|
||
k.ucic,
|
||
k.entityName,
|
||
'',
|
||
'',
|
||
k.panNumber,
|
||
'',
|
||
'',
|
||
k.gstin,
|
||
'',
|
||
'NA',
|
||
'',
|
||
'',
|
||
k.mobileNumber,
|
||
'',
|
||
'',
|
||
'95',
|
||
k.address,
|
||
k.pincode,
|
||
k.address.split(' ').slice(0, 4).join(' '),
|
||
k.state,
|
||
'501',
|
||
k.city,
|
||
k.dateOfIncorporation.replace(/-/g, '/'),
|
||
k.customerType,
|
||
k.lastKycDate.replace(/-/g, '/'),
|
||
k.onboardingDate.replace(/-/g, '/'),
|
||
k.riskLevel,
|
||
k.reportType,
|
||
k.isSoleProprietor ? 'Yes' : 'No',
|
||
k.ownerUcic,
|
||
k.ownerName
|
||
]);
|
||
}
|
||
return { headers, rows };
|
||
}
|
||
|
||
/** Account_Detail — per-report account aggregates (12-month) */
|
||
private buildAccountDetail(): FinnetTable {
|
||
const headers = [
|
||
'Report Type',
|
||
'Report Reference Number',
|
||
'Account Type',
|
||
'Account Number',
|
||
'Branch Code of Account',
|
||
'Date of Account Opening',
|
||
'Date of Account Closing',
|
||
'Account Status',
|
||
'Reason for Account Freeze',
|
||
'No Of Debits (In last 12 months)',
|
||
'Total Debit Amount (In last 12 months)',
|
||
'No Of Credits (In last 12 months)',
|
||
'Total Credit Amount (In last 12 months)',
|
||
'No Of Cash Transaction (In last 12 months)',
|
||
'Total Cash Deposit (In last 12 months)',
|
||
'Total Cash Withdrawal (In last 12 months) Amount'
|
||
];
|
||
const rows: string[][] = [];
|
||
// Build one aggregate row per (reportRefNo, accountNumber) pair
|
||
const seen = new Set<string>();
|
||
for (const t of this.filteredCtr) {
|
||
const key = `${t.reportRefNo}|${t.accountNumber}`;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
const sameKey = this.filteredCtr.filter(
|
||
(x) => x.reportRefNo === t.reportRefNo && x.accountNumber === t.accountNumber
|
||
);
|
||
const debits = sameKey.filter((x) => x.depositOrWithdrawal === 'Withdrawal');
|
||
const credits = sameKey.filter((x) => x.depositOrWithdrawal === 'Deposit');
|
||
const cashTxns = sameKey.filter((x) => x.instrumentType === 'Cash');
|
||
rows.push([
|
||
t.reportType,
|
||
t.reportRefNo.toString(),
|
||
'212',
|
||
t.accountNumber,
|
||
t.branchCode,
|
||
'20/05/2018',
|
||
'',
|
||
'ACTIVE',
|
||
'',
|
||
debits.length.toString(),
|
||
debits.reduce((s, x) => s + x.txnAmount, 0).toString(),
|
||
credits.length.toString(),
|
||
credits.reduce((s, x) => s + x.txnAmount, 0).toString(),
|
||
cashTxns.length.toString(),
|
||
cashTxns
|
||
.filter((x) => x.depositOrWithdrawal === 'Deposit')
|
||
.reduce((s, x) => s + x.txnAmount, 0)
|
||
.toString(),
|
||
cashTxns
|
||
.filter((x) => x.depositOrWithdrawal === 'Withdrawal')
|
||
.reduce((s, x) => s + x.txnAmount, 0)
|
||
.toString()
|
||
]);
|
||
}
|
||
return { headers, rows };
|
||
}
|
||
|
||
/** Account_Person_Relation — account ↔ customer mapping */
|
||
private buildAccountPersonRelation(): FinnetTable {
|
||
const headers = [
|
||
'Report Type',
|
||
'Report Reference Number',
|
||
'Branch Code of Account',
|
||
'Account Number',
|
||
'Relationship Type',
|
||
'Unique Reference Number',
|
||
'Individual / Non-individual',
|
||
'Name of Non-customer'
|
||
];
|
||
const rows: string[][] = [];
|
||
const seen = new Set<string>();
|
||
for (const t of this.filteredCtr) {
|
||
const key = `${t.reportRefNo}|${t.accountNumber}`;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
// Find matching KC1 by reportRefNo (best-effort dummy mapping)
|
||
const k = this.kc1.find((x) => x.reportRefNo === t.reportRefNo);
|
||
rows.push([
|
||
t.reportType,
|
||
t.reportRefNo.toString(),
|
||
t.branchCode,
|
||
t.accountNumber,
|
||
'1',
|
||
k ? k.ucic : '0',
|
||
'Individual',
|
||
t.relationshipFlag === 'Non-Customer' ? t.customerName || '' : ''
|
||
]);
|
||
}
|
||
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) */
|
||
exportStrXml(): void {
|
||
const suspects = this.strSuspects;
|
||
if (!suspects.length) {
|
||
this.toast('No suspect cases to export — nothing matches the current filters');
|
||
return;
|
||
}
|
||
this.writeStrXml(suspects, `STR-${this.bank.reportingMonth.replace(/\s+/g, '-')}.xml`);
|
||
this.toast(`STR XML exported (${suspects.length} suspects)`);
|
||
}
|
||
|
||
/** Serialises a set of suspect cases as FINnet 2.0 STR XML and downloads it. */
|
||
private writeStrXml(suspects: StrTransaction[], filename: string): void {
|
||
const xml: string[] = [];
|
||
xml.push('<?xml version="1.0" encoding="UTF-8"?>');
|
||
xml.push(`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.bank.reportingMonth}">`);
|
||
xml.push(` <REBank buid="${this.bank.reBuid}" name="${this.bank.name}" />`);
|
||
xml.push(` <PrincipalOfficer name="${this.bank.principalOfficer}" email="${this.bank.principalOfficerEmail}" />`);
|
||
for (const s of suspects) {
|
||
xml.push(` <Suspect ref="${s.reportRefNo}" status="${s.status}">`);
|
||
xml.push(` <Account>${s.accountNumber}</Account>`);
|
||
xml.push(
|
||
` <Transaction id="${s.txnId}" date="${s.txnDate}" time="${s.txnTime}" amount="${s.txnAmount}" type="${s.depositOrWithdrawal}" instrument="${s.instrumentType}" />`
|
||
);
|
||
xml.push(` <RiskCategory>${s.riskCategory}</RiskCategory>`);
|
||
xml.push(` <Detection date="${s.detectedOn}" by="${this.escapeXml(s.flaggedBy)}" />`);
|
||
xml.push(` <Narrative>${this.escapeXml(s.narrative)}</Narrative>`);
|
||
if (s.poRemarks) xml.push(` <PORemarks>${this.escapeXml(s.poRemarks)}</PORemarks>`);
|
||
if (s.fiuReferenceNo) {
|
||
xml.push(` <Acknowledgement ref="${this.escapeXml(s.fiuReferenceNo)}" filedOn="${s.filedOn ?? ''}" />`);
|
||
}
|
||
xml.push(' </Suspect>');
|
||
}
|
||
xml.push('</STR>');
|
||
const blob = new Blob([xml.join('\n')], { type: 'application/xml;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
/**
|
||
* 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 {
|
||
// 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 {
|
||
return (s || '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
private saveCsv(rows: string[][], filename: string): void {
|
||
const escape = (v: string): string => {
|
||
const s = (v || '').replace(/"/g, '""');
|
||
if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s}"`;
|
||
return s;
|
||
};
|
||
const csv = rows.map((r) => r.map(escape).join(',')).join('\n');
|
||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
toast(msg: string): void {
|
||
this.toastMsg = msg;
|
||
this.toastVisible = true;
|
||
if (this.toastTimer) clearTimeout(this.toastTimer);
|
||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 2800);
|
||
}
|
||
}
|