Files
cbsnexora/src/app/fiu/fiu.component.ts
T

1798 lines
63 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { SanctionsLogsService } from 'app/reports/sanctions-logs/sanctions-logs.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { toApiDate, toIsoDate } from 'app/core/utils/api-date';
import {
BRANCHES,
BranchMaster,
CardTransaction,
CtrTransaction,
FIU_BANK,
FIU_BRANCH_CODE_TO_CANONICAL,
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 the seeded STR set changes so cached rows are re-seeded. */
private static readonly SEED_VERSION = '2026-01-cash-threshold';
private amlService = inject(AmlService);
private sanctionsLogsService = inject(SanctionsLogsService);
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;
/**
* The CTR lines the bank has actually generated. Empty until the engine has produced a report —
* a filing screen that shows invented accounts is worse than one that shows none, because every
* count, total and CSV on it is then something nobody can submit.
*/
ctrTxns: CtrTransaction[] = [];
ctrLinesLoading = false;
ctrLinesError = '';
/**
* ATM / card-rail cash, which feeds the same CTR as the counter transactions in TC2.
*
* Empty: the sample rows that used to sit here belonged to the sample accounts in TC2, and with
* those gone they referenced accounts this bank does not hold — twelve rows claiming to reconcile
* against a file that had none. Card transactions are not yet sourced from the books, so the file
* is honestly empty rather than populated with rows that cannot be filed.
*/
tc1Txns: CardTransaction[] = [];
strTxns: StrTransaction[] = STR_TXNS;
/**
* KC1 / KC2 — the customer cards that travel with the CTR bundle.
*
* Empty for the same reason as TC1: these described sample customers of sample accounts. A card
* naming a person this bank has never onboarded cannot be filed, and leaving them in would put
* invented customers into a bundle an officer might submit.
*/
kc1: KycIndividual[] = [];
kc2: KycEntity[] = [];
// -------------------- 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';
/** The month being filed, from the clock rather than a constant in the data file. */
reportingMonthLabel = '';
/** Blank unless the signed-in user actually holds the Principal Officer role. */
principalOfficerName = '';
/** CTR is due by the 15th of the month after the one being reported. */
ctrDueDateLabel = '';
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[] = [];
/**
* Confirmed UNSC sanctions matches carrying an STR obligation. Held separately from amlAlerts
* because they are identity-driven rather than transaction-driven — see the template comment.
*/
sanctionsStrCases: any[] = [];
sanctionsStrFilter = '';
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';
}
/** Loads confirmed sanctions matches that owe an STR. */
loadSanctionsStrCases() {
this.sanctionsLogsService.getStrCases(this.sanctionsStrFilter || undefined).subscribe({
next: (rows: any) => (this.sanctionsStrCases = rows || []),
// A failure here must not blank the AML side of the tab, which is unrelated.
error: () => (this.sanctionsStrCases = [])
});
}
strStatusLabel(str: any): string {
if (str?.strStatus === 'FILED') {
return 'FILED';
}
return this.isStrOverdue(str) ? 'OVERDUE' : 'PENDING FILING';
}
strStatusClass(str: any): string {
if (str?.strStatus === 'FILED') {
return 'fiu-pill fiu-pill--ok';
}
return this.isStrOverdue(str) ? 'fiu-pill fiu-pill--danger' : 'fiu-pill fiu-pill--warn';
}
/** Past its 7-day PMLA deadline and still unfiled — the state that actually needs chasing. */
isStrOverdue(str: any): boolean {
if (!str?.strDueDate || str.strStatus === 'FILED') {
return false;
}
return new Date(str.strDueDate) < new Date(new Date().toDateString());
}
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';
// The header named a hard-coded officer and a hard-coded month — "Karthik Kamath" and
// "January 2026" — on a screen whose whole purpose is to say which month is being filed and
// by whom. Both now come from the session and the clock.
const today = new Date();
this.reportingMonthLabel = today.toLocaleDateString('en-IN', { month: 'long', year: 'numeric' });
// The 15th of the following month, which is the PMLA deadline for the month being reported.
this.ctrDueDateLabel = new Date(today.getFullYear(), today.getMonth() + 1, 15).toLocaleDateString('en-IN', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
this.principalOfficerName = this.authenticationService.isPrincipalOfficerUser()
? creds?.staffDisplayName || creds?.username || 'Principal Officer'
: '';
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();
// The CTR grids are built from the reports the bank generated, not from a sample set. Loaded
// after the cached rows below so a fresh read always wins over whatever was stored last.
this.loadCtrLines();
// 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_str_cases', JSON.stringify(this.strTxns));
return;
}
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, '-');
this.storeCtrCompliance();
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';
this.storeCtrCompliance();
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, '-');
this.storeCtrCompliance();
}
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.';
}
/**
* Always fetches every status. The triage table and the cleared register are two views of the
* same set, so filtering server-side would empty one whenever the other was narrowed — and the
* CTR tab reads this list too, which a status filter chosen on the STR tab should not touch.
* amlAlertStatusFilter is applied client-side in {@link amlSuspiciousAlerts} instead.
*/
loadAmlAlerts(): void {
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
this.loadSanctionsStrCases();
}
loadAmlCtrReports(): void {
this.amlService.getCtrReports().subscribe((data) => (this.amlCtrReports = data || []));
}
loadAmlStrCases(): void {
this.amlService.getStrCases().subscribe((data) => (this.amlStrCases = data || []));
}
/**
* Fineract sends timestamps as [y, M, d, H, m, s]. Angular's date pipe renders an array as
* nothing, which left "Raised On" blank beside alerts that had obviously been raised.
*/
asDate(value: any): Date | null {
return toApiDate(value);
}
/**
* Loads the generated CTR lines and shapes them into the TC2 rows every grid on this tab derives
* from — Account_Detail, Account_Person_Relation and TC1 are all computed off `ctrTxns`, so the
* one conversion here puts real data behind all four files.
*/
loadCtrLines(): void {
this.ctrLinesLoading = true;
this.ctrLinesError = '';
this.amlService.getCtrReportLines().subscribe({
next: (lines) => {
this.ctrLinesLoading = false;
this.ctrTxns = (lines || []).map((l: any, i: number) => this.toCtrTransaction(l, i + 1));
// Cached so the compliance flags an officer sets below survive a reload, keyed on the rows
// that actually came back rather than on a seeded set.
this.applyStoredCtrCompliance();
},
error: (err: any) => {
this.ctrLinesLoading = false;
this.ctrTxns = [];
this.ctrLinesError =
err?.error?.defaultUserMessage || 'Could not load the generated CTR lines. Generate a report for the month first.';
}
});
}
/**
* One reportable line as a TC2 row.
*
* A connected-group line is a month of smaller cash movements by one customer that together cross
* the threshold, so it has no single transaction, account or date behind it. It is still
* reportable, and is shown as the aggregate it is rather than dropped.
*/
private toCtrTransaction(line: any, refNo: number): CtrTransaction {
const branchCode = this.branchCodeOfName(line.branchName) || '';
return {
reportType: 'CTR',
reportRefNo: refNo,
branchCode,
accountNumber: line.accountNo || '',
txnBranchCode: branchCode,
depositOrWithdrawal: line.direction === 'Withdrawal' ? 'Withdrawal' : 'Deposit',
txnId: line.transactionId || line.id,
txnDate: toIsoDate(line.transactionDate),
txnTime: '',
txnAmount: Number(line.amount) || 0,
instrumentType: 'Cash',
instrumentId: 'CASH',
nonCustomerRef: '',
relationshipFlag: 'Customer',
thirdPartyPan: '',
declaration: line.connectedGroup ? 'Connected transactions aggregated for the month' : '',
customerName: line.clientName || '',
complianceStatus: 'PENDING',
accountOpenedOn: toIsoDate(line.accountOpenedOn),
accountClosedOn: toIsoDate(line.accountClosedOn),
accountStatus: line.accountStatus || '',
ckycNumber: line.ckycNumber || '',
clientExternalId: line.clientExternalId || '',
individual: line.individual !== false
};
}
/** Branch master code for an office name, so the branch filter on this tab still works. */
private branchCodeOfName(name: string | null): string | null {
if (!name) {
return null;
}
const wanted = name.trim().toLowerCase();
return this.branches.find((b: BranchMaster) => wanted.includes(b.name.toLowerCase()))?.code || null;
}
/**
* Re-applies the evidence an officer recorded against these transactions.
*
* The rows themselves come from the server every load; only the counter-side compliance fields
* are held locally, so they are matched back on transaction id rather than by replacing the row.
*/
private applyStoredCtrCompliance(): void {
const stored = localStorage.getItem('fiu_ctr_compliance');
if (!stored) {
return;
}
try {
const byTxnId: Record<string, any> = JSON.parse(stored);
for (const t of this.ctrTxns) {
const saved = byTxnId[String(t.txnId)];
if (saved) {
t.complianceStatus = saved.complianceStatus;
t.evidenceDocType = saved.evidenceDocType;
t.evidenceReference = saved.evidenceReference;
t.evidencedOn = saved.evidencedOn;
}
}
} catch {
// A corrupted cache must not take the filing screen down with it.
localStorage.removeItem('fiu_ctr_compliance');
}
}
/** Persists only the compliance fields, keyed on transaction id. */
private storeCtrCompliance(): void {
const byTxnId: Record<string, any> = {};
for (const t of this.ctrTxns) {
if (t.complianceStatus && t.complianceStatus !== 'PENDING') {
byTxnId[String(t.txnId)] = {
complianceStatus: t.complianceStatus,
evidenceDocType: t.evidenceDocType,
evidenceReference: t.evidenceReference,
evidencedOn: t.evidencedOn
};
}
}
localStorage.setItem('fiu_ctr_compliance', JSON.stringify(byTxnId));
}
/** 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`);
}
/**
* Clearing or escalating an alert has to carry a reason: review_notes is the only audit record of
* why a flagged transaction was disposed of, and an empty string leaves no trace of the decision.
*/
private reviewNotesOf(alert: any): string | null {
const notes = (this.amlReviewNotes[alert.id] || '').trim();
if (!notes) {
this.toast(`Enter review notes before disposing of alert #${alert.id}`);
return null;
}
return notes;
}
whitelistAlert(alert: any): void {
const notes = this.reviewNotesOf(alert);
if (!notes) {
return;
}
this.amlService.reviewAlert(alert.id, 'WHITELIST', notes).subscribe({
next: () => {
this.toast(`Alert #${alert.id} whitelisted — no STR raised`);
this.loadAmlAlerts();
},
error: this.apiError(`Whitelisting alert #${alert.id}`)
});
}
escalateAlert(alert: any): void {
const notes = this.reviewNotesOf(alert);
if (!notes) {
return;
}
this.amlService.reviewAlert(alert.id, 'ESCALATE', notes).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();
// The grids above are built from these lines, so they are re-read rather than left showing
// the state before the run.
this.loadCtrLines();
},
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));
}
/** Behavioural (non-CTR) alerts. */
private get behaviouralAlerts(): any[] {
const ctrRuleCodes = [
'CTR_THRESHOLD_10L',
'STRUCTURED_CTR_10L_MONTHLY',
'LARGE_CASH_TXN_25L'
];
return this.amlAlerts.filter((a) => !ctrRuleCodes.includes(a.ruleCode));
}
/**
* The STR triage queue: still to decide, or already escalated. Whitelisted alerts are closed
* with no report, so they are not STR business — they live in {@link amlWhitelistedAlerts}.
*/
get amlSuspiciousAlerts(): any[] {
return this.behaviouralAlerts.filter(
(a) => a.status !== 'WHITELISTED' && (!this.amlAlertStatusFilter || a.status === this.amlAlertStatusFilter)
);
}
/** Cleared register: alerts closed as false positives, kept for audit with their evidence. */
get amlWhitelistedAlerts(): any[] {
return this.behaviouralAlerts.filter((a) => a.status === 'WHITELISTED');
}
// -------------------- 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(),
// dd/MM/yyyy, as FINnet prints it. A straight slash-swap on an ISO date gave 2026/09/11,
// which reads as a different date to anyone expecting the prescribed format.
this.toFinnetDate(t.txnDate),
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 };
}
/** FINnet prints dates as dd/MM/yyyy; the API sends them as yyyy-MM-dd. Blank stays blank. */
private toFinnetDate(iso: string | undefined): string {
if (!iso) {
return '';
}
const [
y,
m,
d
] = iso.split('-');
return y && m && d ? `${d}/${m}/${y}` : '';
}
/** 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 is FINnet's savings-account code; every CTR line this module produces is a savings
// transaction, and a loan line would need its own code before it could be filed.
'212',
t.accountNumber,
t.branchCode,
// Read off the account, not assumed. Every row used to carry 20/05/2018 and ACTIVE.
this.toFinnetDate(t.accountOpenedOn),
this.toFinnetDate(t.accountClosedOn),
t.accountStatus || '',
'',
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);
rows.push([
t.reportType,
t.reportRefNo.toString(),
t.branchCode,
t.accountNumber,
'1',
// The customer's own reference: their CKYC number where one is on file, else the external
// id the bank knows them by. This used to read '0' for every row, which identified nobody.
t.ckycNumber || t.clientExternalId || '',
t.individual === false ? 'Non-individual' : '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) */
/**
* Writes the STR XML from the cases the Principal Officer has actually concluded as suspicious.
*
* It used to be built from the sample set, so the file named accounts and customers this bank
* does not hold — a return that could not be filed and should not have been offered.
*/
exportStrXml(): void {
const suspects = (this.amlStrCases || []).filter(
(c: any) => c.conclusion === 'SUSPICIOUS' || c.status === 'CONCLUDED_SUSPICIOUS' || c.status === 'FILED'
);
if (!suspects.length) {
this.toast('No case has been concluded suspicious yet — nothing to file');
return;
}
this.writeLiveStrXml(suspects, `STR-${this.reportingMonthLabel.replace(/\s+/g, '-')}.xml`);
this.toast(`STR XML exported (${suspects.length} case${suspects.length === 1 ? '' : 's'})`);
}
/** Serialises the concluded cases as FINnet 2.0 STR XML and downloads it. */
private writeLiveStrXml(cases: any[], 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.escapeXml(this.reportingMonthLabel)}">`);
xml.push(` <REBank name="${this.escapeXml(this.bank.name)}" />`);
if (this.principalOfficerName) {
xml.push(` <PrincipalOfficer name="${this.escapeXml(this.principalOfficerName)}" />`);
}
for (const c of cases) {
xml.push(` <Suspect ref="${c.id}" status="${this.escapeXml(c.status || '')}">`);
xml.push(` <Customer id="${c.clientId}">${this.escapeXml(c.clientDisplayName || '')}</Customer>`);
xml.push(` <Conclusion>${this.escapeXml(c.conclusion || '')}</Conclusion>`);
if (c.investigationNotes) {
xml.push(` <Narrative>${this.escapeXml(c.investigationNotes)}</Narrative>`);
}
if (c.sourceOfFundsNotes) {
xml.push(` <SourceOfFunds>${this.escapeXml(c.sourceOfFundsNotes)}</SourceOfFunds>`);
}
if (c.fiuReferenceNo) {
xml.push(` <Acknowledgement ref="${this.escapeXml(c.fiuReferenceNo)}" filedOn="${c.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);
}
/** 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
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);
}
}