feat: KYC document update, FIU & AML recurring alerts integration, Sponsor DD revoke workflow, and TDS reports
This commit is contained in:
+253
-32
@@ -13,17 +13,14 @@ 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,
|
||||
CTR_TXNS,
|
||||
TC1_TXNS,
|
||||
FIU_BANK,
|
||||
FIU_BRANCH_CODE_TO_CANONICAL,
|
||||
KC1_INDIVIDUALS,
|
||||
KC2_ENTITIES,
|
||||
KycEntity,
|
||||
KycIndividual,
|
||||
PoFinding,
|
||||
@@ -127,7 +124,7 @@ export const FIU_OBLIGATIONS: FiuObligation[] = [
|
||||
]
|
||||
})
|
||||
export class FiuComponent implements OnInit {
|
||||
/** Bump when CTR_TXNS / STR_TXNS change so cached rows are re-seeded. */
|
||||
/** 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);
|
||||
@@ -140,11 +137,33 @@ export class FiuComponent implements OnInit {
|
||||
readonly obligations = FIU_OBLIGATIONS;
|
||||
|
||||
bank = FIU_BANK;
|
||||
ctrTxns: CtrTransaction[] = CTR_TXNS;
|
||||
tc1Txns: CardTransaction[] = TC1_TXNS;
|
||||
/**
|
||||
* 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: KycIndividual[] = KC1_INDIVIDUALS;
|
||||
kc2: KycEntity[] = KC2_ENTITIES;
|
||||
/**
|
||||
* 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
|
||||
@@ -155,6 +174,12 @@ export class FiuComponent implements OnInit {
|
||||
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 = '';
|
||||
@@ -372,6 +397,21 @@ export class FiuComponent implements OnInit {
|
||||
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 {
|
||||
@@ -384,22 +424,17 @@ export class FiuComponent implements OnInit {
|
||||
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_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);
|
||||
@@ -442,7 +477,7 @@ export class FiuComponent implements OnInit {
|
||||
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.storeCtrCompliance();
|
||||
this.evidencingTxn = null;
|
||||
this.toast(`${txn.customerName} — evidence recorded. Reported in CTR only, no STR referral.`);
|
||||
}
|
||||
@@ -453,7 +488,7 @@ export class FiuComponent implements OnInit {
|
||||
*/
|
||||
referCtrTxnToStr(txn: CtrTransaction): void {
|
||||
txn.complianceStatus = 'REFERRED';
|
||||
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
|
||||
this.storeCtrCompliance();
|
||||
|
||||
const newRef = Math.max(100, ...this.strTxns.map((s) => s.reportRefNo)) + 1;
|
||||
const newStrCase: StrTransaction = {
|
||||
@@ -521,7 +556,7 @@ export class FiuComponent implements OnInit {
|
||||
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.storeCtrCompliance();
|
||||
}
|
||||
this.toast(`STR-${rec.reportRefNo} closed as clean — CTR only, no STR filed`);
|
||||
} else {
|
||||
@@ -598,6 +633,129 @@ export class FiuComponent implements OnInit {
|
||||
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`);
|
||||
@@ -654,6 +812,9 @@ export class FiuComponent implements OnInit {
|
||||
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')
|
||||
});
|
||||
@@ -1016,7 +1177,9 @@ export class FiuComponent implements OnInit {
|
||||
t.txnBranchCode,
|
||||
t.depositOrWithdrawal,
|
||||
t.txnId.toString(),
|
||||
t.txnDate.replace(/-/g, '/'),
|
||||
// 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,
|
||||
@@ -1254,6 +1417,19 @@ export class FiuComponent implements OnInit {
|
||||
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 = [
|
||||
@@ -1290,12 +1466,15 @@ export class FiuComponent implements OnInit {
|
||||
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,
|
||||
'20/05/2018',
|
||||
'',
|
||||
'ACTIVE',
|
||||
// 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(),
|
||||
@@ -1333,16 +1512,16 @@ export class FiuComponent implements OnInit {
|
||||
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',
|
||||
// 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 || '' : ''
|
||||
]);
|
||||
}
|
||||
@@ -1488,14 +1667,56 @@ export class FiuComponent implements OnInit {
|
||||
}
|
||||
|
||||
/** 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.strSuspects;
|
||||
const suspects = (this.amlStrCases || []).filter(
|
||||
(c: any) => c.conclusion === 'SUSPICIOUS' || c.status === 'CONCLUDED_SUSPICIOUS' || c.status === 'FILED'
|
||||
);
|
||||
if (!suspects.length) {
|
||||
this.toast('No suspect cases to export — nothing matches the current filters');
|
||||
this.toast('No case has been concluded suspicious yet — nothing to file');
|
||||
return;
|
||||
}
|
||||
this.writeStrXml(suspects, `STR-${this.bank.reportingMonth.replace(/\s+/g, '-')}.xml`);
|
||||
this.toast(`STR XML exported (${suspects.length} suspects)`);
|
||||
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. */
|
||||
|
||||
Reference in New Issue
Block a user