feat: KYC document update, FIU & AML recurring alerts integration, Sponsor DD revoke workflow, and TDS reports

This commit is contained in:
2026-09-14 11:43:26 +05:30
parent 7afa9c7a50
commit 2124bb4b76
40 changed files with 3028 additions and 929 deletions
+253 -32
View File
@@ -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. */