feat: KYC document update, FIU & AML recurring alerts integration, Sponsor DD revoke workflow, and TDS reports
This commit is contained in:
@@ -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
@@ -14,8 +14,8 @@
|
||||
<div>
|
||||
<h2 class="fiu-head-t">Financial Intelligence Unit – India (FIU-IND)</h2>
|
||||
<div class="fiu-head-s">
|
||||
{{ bank.name }} · {{ scopeName }} · Reporting Month <strong>{{ bank.reportingMonth }}</strong>
|
||||
· PMLA 2002 / FINnet 2.0
|
||||
{{ bank.name }} · {{ scopeName }} · Reporting Month
|
||||
<strong>{{ reportingMonthLabel }}</strong> · 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>₹{{ 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 }} — {{ alert.ruleDescription }}</td>
|
||||
<td class="ta-r mono">₹{{ 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 }} — {{ alert.ruleDescription }}</td>
|
||||
<td class="ta-r mono">₹{{ 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>₹{{ 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> · 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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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