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
+16
View File
@@ -65,6 +65,22 @@ export interface CtrTransaction {
evidenceDocType?: string;
evidenceReference?: string;
evidencedOn?: string;
/**
* Account and customer facts carried alongside the transaction, read off the books when the CTR
* line is loaded.
*
* Account_Detail and Account_Person_Relation are derived from these rows, and they used to fill
* these columns with constants — every account opened on 20/05/2018, every account ACTIVE, every
* customer reference '0'. Those are statements about a customer's account in a filing, so they
* come from the record rather than from a placeholder.
*/
accountOpenedOn?: string;
accountClosedOn?: string;
accountStatus?: string;
ckycNumber?: string;
clientExternalId?: string;
individual?: boolean;
}
/** Outcome of the Principal Officer's investigation into an STR case */
+33 -301
View File
@@ -14,8 +14,8 @@
<div>
<h2 class="fiu-head-t">Financial Intelligence Unit – India (FIU-IND)</h2>
<div class="fiu-head-s">
{{ bank.name }} &middot; {{ scopeName }} &middot; Reporting Month <strong>{{ bank.reportingMonth }}</strong>
&middot; PMLA 2002 / FINnet 2.0
{{ bank.name }} &middot; {{ scopeName }} &middot; Reporting Month
<strong>{{ reportingMonthLabel }}</strong> &middot; PMLA 2002 / FINnet 2.0
</div>
</div>
</div>
@@ -33,12 +33,13 @@
<span class="fiu-meta-v">{{ scopeName }}</span>
}
</div>
<div>
<span class="fiu-meta-l">Principal Officer</span><span class="fiu-meta-v">{{ bank.principalOfficer }}</span>
</div>
<div>
<span class="fiu-meta-l">REBANK BUID</span><span class="fiu-meta-v mono">{{ bank.reBuid }}</span>
</div>
<!-- Named only when the person at the screen actually holds the role. A filing screen that
names an officer who is not the one working it misstates who signed the return. -->
@if (principalOfficerName) {
<div>
<span class="fiu-meta-l">Principal Officer</span><span class="fiu-meta-v">{{ principalOfficerName }}</span>
</div>
}
</div>
</div>
@@ -164,7 +165,9 @@
<div class="dc-meta">
Threshold: <strong>&#8377;{{ bank.cashThreshold | number }}</strong> per single txn / aggregate per month
</div>
<div class="dc-meta">Due: <strong>15 Feb 2026</strong></div>
<!-- The 15th of the month after the one being reported, worked out rather than fixed at
15 Feb 2026 — a due date that never moves stops being a due date. -->
<div class="dc-meta">Due: <strong>{{ ctrDueDateLabel }}</strong></div>
<button class="dc-btn" (click)="showTab('ctr')"><fa-icon icon="external-link-alt"></fa-icon> Open</button>
</div>
<div class="dc-card">
@@ -206,11 +209,29 @@
with the same bundle and are worked on the Customer Cards tab.
</p>
</div>
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
<button class="fiu-btn fiu-btn-xls" [disabled]="!ctrTxns.length" (click)="exportAll()">
<fa-icon icon="file-csv"></fa-icon> Download full bundle
</button>
</div>
<!-- The grids are built from the reports the bank generated. Until one exists there is
nothing to file, and saying so is better than a screen of plausible-looking rows
nobody can submit. -->
@if (ctrLinesLoading) {
<div class="fiu-note">Loading the generated CTR lines…</div>
} @else if (ctrLinesError) {
<div class="fiu-note fiu-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}
</div>
} @else if (!ctrTxns.length) {
<div class="fiu-note fiu-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
No CTR has been generated yet. Pick a month under “Generate CTR” below and run it — every cash transaction
of ₹10,00,000 or more, and every customer whose smaller cash transactions add up to that within the month,
will be picked up from the books.
</div>
}
<!-- Scope: these controls decide which transactions the six files carry. -->
<div class="fiu-filters">
<div class="fiu-filter">
@@ -450,7 +471,7 @@
<td>{{ alert.transactionType }}</td>
<td>{{ alert.ruleCode }} &mdash; {{ alert.ruleDescription }}</td>
<td class="ta-r mono">&#8377;{{ alert.amount | number: '1.0-0' }}</td>
<td>{{ alert.raisedOn | date: 'medium' }}</td>
<td>{{ asDate(alert.raisedOn) | date: 'medium' }}</td>
<td>
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
</td>
@@ -523,7 +544,7 @@
<td>{{ alert.transactionType }}</td>
<td>{{ alert.ruleCode }} &mdash; {{ alert.ruleDescription }}</td>
<td class="ta-r mono">&#8377;{{ alert.amount | number: '1.0-0' }}</td>
<td>{{ alert.raisedOn | date: 'medium' }}</td>
<td>{{ asDate(alert.raisedOn) | date: 'medium' }}</td>
<td>{{ alert.reviewedByUsername || '—' }}</td>
<td>
<span class="muted">{{ alert.reviewNotes || '—' }}</span>
@@ -744,295 +765,6 @@
<fa-icon icon="file-alt"></fa-icon> Download STR XML
</button>
</div>
<div class="fiu-filters">
<div class="fiu-filter">
<label>Status</label>
<select class="fiu-inp" [(ngModel)]="strFilterStatus">
<option value="">All</option>
<option value="draft">Draft</option>
<option value="po-review">PO Review</option>
<option value="awaiting-filing">Awaiting Filing</option>
<option value="filed">Filed</option>
<option value="rejected">Closed — Clean</option>
</select>
</div>
<div class="fiu-filter">
<label>Risk Category</label>
<select class="fiu-inp" [(ngModel)]="strFilterRisk">
<option value="">All</option>
<option value="structuring">Structuring</option>
<option value="dormant-revival">Dormant Revival</option>
<option value="high-risk-country">High-Risk Country</option>
<option value="unusual-pattern">Unusual Pattern</option>
<option value="pep-related">PEP Related</option>
<option value="sanctions-hit">Sanctions Hit</option>
</select>
</div>
<button class="fiu-btn fiu-btn-g" (click)="resetStrFilters()">Reset</button>
</div>
<div class="fiu-str-list">
@for (s of filteredStr; track s.reportRefNo) {
<div
class="str-card"
[id]="'str-card-' + s.reportRefNo"
[class.str-card--filed]="s.status === 'filed'"
[class.str-card--highlight]="highlightedStrRef === s.reportRefNo"
>
<div class="str-card-h">
<div class="str-card-id">
<span class="str-ref">STR-{{ s.reportRefNo }}</span>
<span class="status-pill" [class]="'st-' + s.status">{{ statusLabel(s.status) }}</span>
<span class="risk-pill" [class]="'rk-' + s.riskCategory">{{ riskLabel(s.riskCategory) }}</span>
</div>
<div class="str-card-amt">
<span class="muted">{{ s.depositOrWithdrawal }}</span>
<strong>&#8377;{{ s.txnAmount | number: '1.0-0' }}</strong>
</div>
</div>
<div class="str-card-body">
<div class="str-meta">
<div>
<label>Customer</label><span>{{ s.customerName }}</span>
</div>
<div>
<label>Account</label><span class="mono">{{ s.accountNumber }}</span>
</div>
<div>
<label>Txn Date</label><span>{{ s.txnDate }} {{ s.txnTime }}</span>
</div>
<div>
<label>Detected</label><span>{{ s.detectedOn }}</span>
</div>
<div>
<label>Flagged By</label><span>{{ s.flaggedBy }}</span>
</div>
<div>
<label>Instrument</label><span>{{ s.instrumentType }} {{ s.instrumentId }}</span>
</div>
<div>
<label>Branch</label><span class="mono">{{ s.branchCode }} {{ branchNameFor(s.branchCode) }}</span>
</div>
</div>
<div class="str-narrative">
<label>Narrative</label>
<p>{{ s.narrative }}</p>
</div>
@if (s.poRemarks) {
<div class="str-po-remarks">
<label><fa-icon icon="check-circle"></fa-icon> PO Remarks</label>
<p>{{ s.poRemarks }}</p>
</div>
}
@if (s.poFinding) {
<div class="str-finding" [class.str-finding--suspicious]="s.poFinding === 'SUSPICIOUS'">
<label>
<fa-icon [icon]="s.poFinding === 'CLEAN' ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
Principal Officer Finding
</label>
<p>{{ poFindingLabel(s.poFinding) }}</p>
</div>
}
<!-- Concluded suspicious but not yet submitted to FIU-IND -->
@if (s.status === 'awaiting-filing') {
<div class="str-filing" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<label>
<fa-icon icon="exclamation-triangle"></fa-icon> Awaiting submission to FIU-IND — due within 7 days
of {{ s.detectedOn }}
</label>
<p class="filing-hint">
Generate the FINnet STR XML, upload it on the FIU-IND portal under
{{ bank.principalOfficer }}'s digital signature, then record the acknowledgement number here.
</p>
<div class="filing-row">
<button class="fiu-btn" (click)="exportSingleStrXml(s)">
<fa-icon icon="file-alt"></fa-icon> Generate STR XML
</button>
<input
class="fiu-inp"
type="text"
placeholder="FIU-IND acknowledgement no."
[(ngModel)]="localFileRefs[s.reportRefNo]"
/>
<button class="fiu-btn fiu-btn-xml" (click)="fileLocalStr(s)">Mark STR Filed</button>
</div>
</div>
}
@if (s.status === 'filed' && s.fiuReferenceNo) {
<div class="str-filed-ack">
<fa-icon icon="check-circle"></fa-icon>
<span>
Filed with FIU-IND on <strong>{{ s.filedOn }}</strong> &middot; acknowledgement
<strong class="mono">{{ s.fiuReferenceNo }}</strong>
</span>
</div>
}
@if (!s.poFinding && investigatingStr?.reportRefNo !== s.reportRefNo) {
<div class="str-po-action" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<label>Principal Officer investigation — is the source of funds legitimate?</label>
<button class="fiu-btn fiu-btn-xml" (click)="investigateLocalStr(s)">
<fa-icon icon="search"></fa-icon> Investigate this case
</button>
</div>
}
<!-- Investigation workspace for this case -->
@if (investigatingStr?.reportRefNo === s.reportRefNo) {
<div class="str-investigation" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<div class="inv-head">
<span>Principal Officer investigation — STR-{{ s.reportRefNo }}</span>
<button class="fiu-link-btn fiu-link-btn--muted" (click)="closeLocalStrInvestigation()">
Cancel
</button>
</div>
<!-- Step 1 — call for evidence -->
<div class="inv-step">
<div class="inv-step-t"><span class="inv-num">1</span> Documentary evidence called for</div>
<div class="inv-grid">
<div class="form-row">
<label for="invDocType">Document requested from the customer</label>
<select id="invDocType" class="fiu-inp" [(ngModel)]="evidenceDocType">
<option value="">Select document…</option>
@for (d of evidenceDocTypes; track d) {
<option [value]="d">{{ d }}</option>
}
</select>
</div>
<div class="form-row">
<label for="invReceived">Was it produced?</label>
<select id="invReceived" class="fiu-inp" [(ngModel)]="evidenceReceived">
<option value="">Select…</option>
<option value="YES">Yes — complete</option>
<option value="PARTIAL">Partially — incomplete</option>
<option value="NO">No — not produced</option>
</select>
</div>
<div class="form-row">
<label for="invRef">Document / reference number</label>
<input id="invRef" class="fiu-inp" type="text" [(ngModel)]="evidenceReference" />
</div>
</div>
</div>
<!-- Step 2 — customer's account of the funds -->
<div class="inv-step">
<div class="inv-step-t"><span class="inv-num">2</span> Customer’s explanation of the source</div>
<textarea
rows="2"
[(ngModel)]="customerExplanation"
placeholder="e.g. Proceeds of a flat sold in June; sale deed produced and matched to the deposit amount and date."
></textarea>
</div>
<!-- Step 3 — verification checklist -->
<div class="inv-step">
<div class="inv-step-t">
<span class="inv-num">3</span> Verification checklist
<span class="checklist-progress" [class.done]="allAnswered">
{{ answeredCount }} / {{ strChecklist.length }} answered
</span>
</div>
<table class="checklist-tbl">
<thead>
<tr>
<th>Verification point</th>
<th class="ta-c col-yes">Genuine</th>
<th class="ta-c col-no">Not satisfied</th>
</tr>
</thead>
<tbody>
@for (item of strChecklist; track item.key) {
<tr
[class.row-yes]="strFindings[item.key] === true"
[class.row-no]="strFindings[item.key] === false"
[class.row-pending]="strFindings[item.key] === null"
>
<td>
<div class="chk-label">{{ item.label }}</div>
<div class="chk-hint">{{ item.hint }}</div>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--yes"
[class.on]="strFindings[item.key] === true"
[attr.aria-label]="'Mark ' + item.label + ' as genuine'"
(click)="setFinding(item.key, true)"
>
<fa-icon icon="check-circle"></fa-icon>
</button>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--no"
[class.on]="strFindings[item.key] === false"
[attr.aria-label]="'Mark ' + item.label + ' as not satisfied'"
(click)="setFinding(item.key, false)"
>
<fa-icon icon="exclamation-triangle"></fa-icon>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Derived outcome -->
<div
class="verdict"
[class.verdict--clear]="canConcludeLocal && derivedConclusion === 'NOT_SUSPICIOUS'"
[class.verdict--suspicious]="canConcludeLocal && derivedConclusion === 'SUSPICIOUS'"
>
@if (!canConcludeLocal) {
<fa-icon icon="info-circle"></fa-icon>
<span>
Complete all three steps to determine the outcome.
@if (!evidenceRecorded) {
<span> Evidence not yet recorded.</span>
}
@if (!allAnswered) {
<span> {{ strChecklist.length - answeredCount }} verification point(s) pending.</span>
}
</span>
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
<fa-icon icon="check-circle"></fa-icon>
<span>
<strong>Source of funds established.</strong> Case closes as clean — the transaction stays in
the CTR report and no STR is filed with FIU-IND.
</span>
} @else {
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
<strong>Source of funds not established.</strong> {{ failedFindings.length }} point(s)
unsatisfied — an STR will be filed with FIU-IND in addition to the CTR.
</span>
}
</div>
<button class="fiu-btn fiu-btn-xml" [disabled]="!canConcludeLocal" (click)="concludeLocalStr()">
{{
canConcludeLocal
? derivedConclusion === 'SUSPICIOUS'
? 'Conclude — file STR with FIU-IND'
: 'Conclude — close as clean (CTR only)'
: 'Conclude'
}}
</button>
</div>
}
</div>
</div>
} @empty {
<div class="fiu-empty-card">No STR records match the filters.</div>
}
</div>
</div>
}
+11
View File
@@ -1086,6 +1086,17 @@
flex-shrink: 0;
margin-top: 2px;
}
/* Nothing to file yet, or the lines could not be read — not the neutral blue of an explanation. */
&--warn {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
fa-icon {
color: #d97706;
}
}
}
/* TOAST */
+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. */