Merge branch 'uate' into dev — sync branches to same level
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-08-04 11:35:08 +05:30
17 changed files with 1113 additions and 1347 deletions
+89
View File
@@ -0,0 +1,89 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* AML transaction-monitoring API: live alerts raised by the rule engine, the
* two-tier AML Officer / Principal Officer review workflow, STR cases (with
* their 7-working-day FIU filing deadline), monthly CTR reports, and a
* client's risk-category change history.
*/
@Injectable({
providedIn: 'root'
})
export class AmlService {
private http = inject(HttpClient);
// ═══ Alerts (AML Officer) ═══
getAlerts(status?: string): Observable<any[]> {
const params = status ? new HttpParams().set('status', status) : undefined;
return this.http.get<any[]>('/aml/alerts', { params });
}
/** disposition: 'WHITELIST' | 'ESCALATE' */
reviewAlert(alertId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
}
// ═══ STR cases (Principal Officer) ═══
getStrCases(): Observable<any[]> {
return this.http.get<any[]>('/aml/str-cases');
}
getStrCase(strCaseId: number): Observable<any> {
return this.http.get(`/aml/str-cases/${strCaseId}`);
}
getWeeklyDigest(): Observable<any[]> {
return this.http.get<any[]>('/aml/str-cases/weekly-digest');
}
assignPrincipalOfficer(strCaseId: number, principalOfficerUserId: number): Observable<any> {
return this.http.post(`/aml/str-cases/${strCaseId}/assign`, { principalOfficerUserId });
}
/** conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' */
concludeStrCase(
strCaseId: number,
conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS',
investigationNotes: string,
sourceOfFundsNotes: string
): Observable<any> {
return this.http.post(`/aml/str-cases/${strCaseId}/conclude`, {
conclusion,
investigationNotes,
sourceOfFundsNotes
});
}
fileStr(strCaseId: number, referenceNo: string): Observable<any> {
return this.http.post(`/aml/str-cases/${strCaseId}/file`, { referenceNo });
}
// ═══ CTR reports (mechanical, threshold-based) ═══
getCtrReports(): Observable<any[]> {
return this.http.get<any[]>('/aml/ctr-reports');
}
/** month format: yyyy-MM */
generateCtrReport(month: string): Observable<any> {
return this.http.post('/aml/ctr-reports/generate', { month });
}
// ═══ Client risk-category history ═══
getRiskCategoryHistory(clientId: number): Observable<any[]> {
return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`);
}
}
@@ -2134,7 +2134,15 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
return generalDetails;
}
get calculatedRisk(): { category: number; reason: string } {
get calculatedRisk(): {
category: number;
reason: string;
amlCheckStatus?: string;
bannedEntityMatch?: string;
willfulDefaulter?: string;
ecgcSalFlag?: string;
dormantFlag?: string;
} {
const legalFormId = this.createClientForm.get('legalFormId')?.value || 1; // 1 = Person, 2 = Entity
const occCatId = this.createClientForm.get('occupationCategoryCvId')?.value;
const occId = this.createClientForm.get('occupationCvId')?.value;
@@ -2151,6 +2159,21 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// Evaluate rules from DB (ordered by orderPosition ASC)
if (this.riskRules && this.riskRules.length > 0) {
for (const rule of this.riskRules) {
// Skip transaction/AML rules that have no KYC criteria
if (
rule.legalFormId == null &&
rule.occupationCategoryCvId == null &&
rule.occupationCvId == null &&
rule.minMonthlyIncome == null &&
rule.maxMonthlyIncome == null &&
rule.minAnnualIncome == null &&
rule.maxAnnualIncome == null &&
rule.minAnnualTurnover == null &&
rule.maxAnnualTurnover == null
) {
continue;
}
// Match legal form
if (rule.legalFormId != null && rule.legalFormId !== legalFormId) {
continue;
@@ -2191,15 +2214,39 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
}
// All matched!
return { category: rule.riskCategory, reason: rule.reason };
return {
category: rule.riskCategory,
reason: rule.reason,
amlCheckStatus: rule.amlCheckStatus || (rule.riskCategory === 3 ? 'FLAGGED' : 'CLEAR'),
bannedEntityMatch: rule.bannedEntityMatch || 'NO',
willfulDefaulter: rule.willfulDefaulter || 'NO',
ecgcSalFlag: rule.ecgcSalFlag || 'NO',
dormantFlag: rule.dormantFlag || 'ACTIVE'
};
}
}
// Default Fallbacks
if (legalFormId !== LegalFormId.PERSON) {
return { category: 2, reason: 'Non-Individual / Entity profile (Default Medium Risk)' };
return {
category: 2,
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
amlCheckStatus: 'CLEAR',
bannedEntityMatch: 'NO',
willfulDefaulter: 'NO',
ecgcSalFlag: 'NO',
dormantFlag: 'ACTIVE'
};
}
return { category: 1, reason: 'Default Low Risk profile (Salary / Service / Agriculture / Others)' };
return {
category: 1,
reason: 'Default Low Risk profile (Salary / Service / Agriculture / Others)',
amlCheckStatus: 'CLEAR',
bannedEntityMatch: 'NO',
willfulDefaulter: 'NO',
ecgcSalFlag: 'NO',
dormantFlag: 'ACTIVE'
};
}
}
@@ -1251,6 +1251,12 @@
</h4>
<div class="form-grid">
<div class="checkbox-field override-checkbox-wrapper full-width">
<mat-checkbox formControlName="allowAmlOverride" [disabled]="readOnly">
Override Auto-calculated AML &amp; Compliance Checks
</mat-checkbox>
</div>
<mat-form-field appearance="outline" class="form-field">
<mat-label>AML Check Status</mat-label>
<mat-select required formControlName="amlCheckStatus">
@@ -71,7 +71,15 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
@Input() entityName: string = '';
/** Read-only mode — disables form editing until user clicks edit icon */
@Input() readOnly = false;
@Input() calculatedRisk: { category: number; reason: string } | null = null;
@Input() calculatedRisk: {
category: number;
reason: string;
amlCheckStatus?: string;
bannedEntityMatch?: string;
willfulDefaulter?: string;
ecgcSalFlag?: string;
dormantFlag?: string;
} | null = null;
today = new Date();
kycId: string = '';
@@ -458,6 +466,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
kycDocExpiryDate: [''],
riskCategory: [1],
allowRiskOverride: [false],
allowAmlOverride: [false],
remarks: [''],
amlCheckStatus: [
'CLEAR',
@@ -535,9 +544,35 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
}
});
this.kycForm.get('allowAmlOverride')?.valueChanges.subscribe((allowed: boolean) => {
const amlControls = [
this.kycForm.get('amlCheckStatus'),
this.kycForm.get('bannedEntityMatch'),
this.kycForm.get('willfulDefaulter'),
this.kycForm.get('ecgcSalFlag'),
this.kycForm.get('dormantFlag')
];
if (allowed) {
if (!this.readOnly) {
amlControls.forEach((ctrl) => ctrl?.enable());
}
} else {
amlControls.forEach((ctrl) => ctrl?.disable());
this.applyAutoCalculatedAmlFlags();
}
});
if (!this.kycForm.get('allowRiskOverride')?.value) {
this.kycForm.get('riskCategory')?.disable();
}
if (!this.kycForm.get('allowAmlOverride')?.value) {
this.kycForm.get('amlCheckStatus')?.disable();
this.kycForm.get('bannedEntityMatch')?.disable();
this.kycForm.get('willfulDefaulter')?.disable();
this.kycForm.get('ecgcSalFlag')?.disable();
this.kycForm.get('dormantFlag')?.disable();
}
}
/** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs,
@@ -645,6 +680,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
if (!this.kycForm.get('allowRiskOverride')?.value) {
this.kycForm.get('riskCategory')?.disable({ emitEvent: false });
}
if (!this.kycForm.get('allowAmlOverride')?.value) {
this.kycForm.get('amlCheckStatus')?.disable({ emitEvent: false });
this.kycForm.get('bannedEntityMatch')?.disable({ emitEvent: false });
this.kycForm.get('willfulDefaulter')?.disable({ emitEvent: false });
this.kycForm.get('ecgcSalFlag')?.disable({ emitEvent: false });
this.kycForm.get('dormantFlag')?.disable({ emitEvent: false });
}
}
}
if (changes['calculatedRisk']) {
@@ -657,6 +699,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
riskControl.setValue(newRisk.category);
riskControl.markAsDirty();
}
const allowAmlOverride = this.kycForm.get('allowAmlOverride')?.value;
if (!allowAmlOverride) {
this.applyAutoCalculatedAmlFlags(newRisk);
}
} else {
this.riskCalculationReason = '';
}
@@ -871,6 +918,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// Risk & Remarks
riskCategory: data.riskCategory || 1,
allowRiskOverride: this.calculatedRisk ? this.calculatedRisk.category !== (data.riskCategory || 1) : false,
allowAmlOverride: this.calculatedRisk
? this.calculatedRisk.amlCheckStatus !== (data.amlCheckStatus || 'CLEAR') ||
this.calculatedRisk.bannedEntityMatch !== (data.bannedEntityMatch || 'NO') ||
this.calculatedRisk.willfulDefaulter !== (data.willfulDefaulter || 'NO') ||
this.calculatedRisk.ecgcSalFlag !== (data.ecgcSalFlag || 'NO') ||
this.calculatedRisk.dormantFlag !== (data.dormantFlag || 'ACTIVE')
: false,
remarks: data.remarks || '',
hasPan: !!data.panNumber,
// AML & Compliance
@@ -1805,6 +1859,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
v.form15Applicable
);
}
private applyAutoCalculatedAmlFlags(risk = this.calculatedRisk): void {
if (!risk) return;
this.kycForm.get('amlCheckStatus')?.setValue(risk.amlCheckStatus || 'CLEAR');
this.kycForm.get('bannedEntityMatch')?.setValue(risk.bannedEntityMatch || 'NO');
this.kycForm.get('willfulDefaulter')?.setValue(risk.willfulDefaulter || 'NO');
this.kycForm.get('ecgcSalFlag')?.setValue(risk.ecgcSalFlag || 'NO');
this.kycForm.get('dormantFlag')?.setValue(risk.dormantFlag || 'ACTIVE');
this.kycForm.get('amlCheckStatus')?.markAsDirty();
this.kycForm.get('bannedEntityMatch')?.markAsDirty();
this.kycForm.get('willfulDefaulter')?.markAsDirty();
this.kycForm.get('ecgcSalFlag')?.markAsDirty();
this.kycForm.get('dormantFlag')?.markAsDirty();
}
}
/**
@@ -392,6 +392,22 @@ export class AuthenticationService {
return roleNames.some((n) => n === 'clerk');
}
/** Returns true when the logged-in user has the "Principal Officer" role (STR conclusion/filing authority). */
isPrincipalOfficerUser(): boolean {
const creds = this.getCredentials();
if (!creds || !creds.roles) return false;
const roleNames: string[] = (creds.roles as any[]).map((r) => (r.name || r.role || '').toLowerCase().trim());
return roleNames.some((n) => n === 'principal officer');
}
/** Returns true when the logged-in user has the "AML Officer" role (first-level alert review). */
isAmlOfficerUser(): boolean {
const creds = this.getCredentials();
if (!creds || !creds.roles) return false;
const roleNames: string[] = (creds.roles as any[]).map((r) => (r.name || r.role || '').toLowerCase().trim());
return roleNames.some((n) => n === 'aml officer');
}
/**
* Returns true ONLY for regular cashier role (not Head Cashier).
* Regular cashier → full-screen transaction entry, no nav.
+9 -7
View File
@@ -1167,10 +1167,6 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="chart-bar" class="menu-item-icon"></fa-icon> Depreciation Register
</button>
<button mat-menu-item routerLink="/fiu" tabindex="0">
<fa-icon icon="shield-alt" class="menu-item-icon" style="color: #dc2626"></fa-icon> Financial Intelligence
Unit (FIU-IND)
</button>
</div>
<!-- Investment -->
<div class="deposit-menu-group">
@@ -1571,7 +1567,8 @@
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('CTR / STR')"
(click)="rbiMenuOpen = false; openCtrStrDialog()"
routerLink="/fiu"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="calculator" class="gl-icon"></fa-icon> CTR / STR
</a>
@@ -1668,8 +1665,13 @@
>
<fa-icon icon="bell" class="gl-icon"></fa-icon> Daily Position Notice
</a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('R/SLR')" (click)="rbiMenuOpen = false">
<fa-icon icon="lock" class="gl-icon"></fa-icon> R/SLR
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('AML')"
routerLink="/rbi/aml"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML
</a>
</div>
<!-- OSS Reports -->
-17
View File
@@ -280,23 +280,6 @@ export class ShellComponent implements OnInit, OnDestroy {
});
}
/**
* Opens the CTR & STR Compliance Center dialog.
*/
async openCtrStrDialog(): Promise<void> {
const { CtrStrDialogComponent } = await import('../../home/dashboard/ctr-str-dialog/ctr-str-dialog.component');
this.dialog.open(CtrStrDialogComponent, {
width: '95vw',
maxWidth: '1280px',
maxHeight: '92vh',
panelClass: 'crr-slr-dialog-panel',
autoFocus: false,
disableClose: false,
hasBackdrop: true,
backdropClass: 'ckyc-rpt-dlg-backdrop'
});
}
/**
* Opens the CIMS / XBRL Returns dialog. The initial form (OSS-2, PSA, RAL, RCP-UCB)
* is selected based on the menu link the user clicked; user can switch between
+221 -2
View File
@@ -129,9 +129,130 @@
<!-- CTR -->
@if (activeTab === 'ctr') {
<div class="fiu-section">
<!-- LIVE: cash-related AML alerts awaiting AML Officer review -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>CTR — Cash Transaction Report</h3>
<h3>Live Cash Alerts</h3>
<p>
Raised by the transaction-monitoring rule engine — review and whitelist or escalate to Principal Officer
</p>
</div>
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
<option value="">All statuses</option>
<option value="RAISED">Raised</option>
<option value="UNDER_REVIEW">Under Review</option>
<option value="WHITELISTED">Whitelisted</option>
<option value="ESCALATED">Escalated</option>
</select>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th>Type</th>
<th>Rule</th>
<th class="ta-r">Amount</th>
<th>Raised On</th>
<th>Status</th>
<th>Notes</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (alert of amlAlerts; track alert.id) {
<tr>
<td>{{ alert.id }}</td>
<td>{{ alert.clientDisplayName }}</td>
<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>
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<input
type="text"
class="fiu-inp"
placeholder="Review notes"
[(ngModel)]="amlReviewNotes[alert.id]"
/>
} @else {
<span class="muted">{{ alert.reviewNotes }}</span>
}
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<button class="fiu-btn fiu-btn-g" (click)="whitelistAlert(alert)">Whitelist</button>
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</button>
</div>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="fiu-empty">No live alerts.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>Monthly CTR Reports</h3>
<p>
Threshold: <strong>&#8377;{{ bank.cashThreshold | number }}</strong> per single txn / connected aggregate
per month
</p>
</div>
<div class="fiu-action-group">
<input class="fiu-inp" type="month" [(ngModel)]="ctrGenerateMonth" />
<button class="fiu-btn fiu-btn-g" *mifosxHasPermission="'REVIEW_AML_ALERT'" (click)="generateCtrReport()">
Generate
</button>
</div>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>Month</th>
<th>Generated On</th>
<th class="ta-c">Records</th>
<th>Status</th>
<th>Submitted On</th>
</tr>
</thead>
<tbody>
@for (r of amlCtrReports; track r.id) {
<tr>
<td>{{ r.reportMonth }}</td>
<td>{{ r.generatedOn | date: 'medium' }}</td>
<td class="ta-c">{{ r.recordCount }}</td>
<td>
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="fiu-empty">No CTR reports generated yet.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- FINnet export template (separate concern — TC1/TC2 file-format generation) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>FINnet TC2 Export Template</h3>
<p>
All cash transactions ≥ &#8377;{{ bank.cashThreshold | number }} (single or aggregate per month per account)
</p>
@@ -221,9 +342,107 @@
<!-- STR -->
@if (activeTab === 'str') {
<div class="fiu-section">
<!-- LIVE: STR cases with full Principal Officer actions -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>STR — Suspicious Transaction Report</h3>
<h3>Live STR Cases</h3>
<p>
Escalated by an AML Officer; Principal Officer investigates, concludes and files with FIU-IND within 7
working days
</p>
</div>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th class="ta-c">Cumulative Cases</th>
<th>Assigned PO</th>
<th>Conclusion</th>
<th>FIU Due Date</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (sc of amlStrCases; track sc.id) {
<tr [class.selected-row]="selectedStrCase?.id === sc.id">
<td>{{ sc.id }}</td>
<td>{{ sc.clientDisplayName }}</td>
<td class="ta-c" [class.escalation-warning]="sc.cumulativeStrCaseCountForClient >= 3">
{{ sc.cumulativeStrCaseCountForClient }}
</td>
<td>{{ sc.assignedPoUsername || '—' }}</td>
<td>{{ sc.conclusion || '—' }}</td>
<td>{{ sc.fiuDueDate || '—' }}</td>
<td>
<span [class]="amlStatusClass(sc.status)">{{ sc.status }}</span>
</td>
<td><button class="fiu-btn fiu-btn-g" (click)="selectStrCase(sc)">Investigate</button></td>
</tr>
} @empty {
<tr>
<td colspan="8" class="fiu-empty">No STR cases yet.</td>
</tr>
}
</tbody>
</table>
</div>
@if (selectedStrCase) {
<div class="str-case-detail">
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>STR Case #{{ selectedStrCase.id }} — {{ selectedStrCase.clientDisplayName }}</h3>
<p>
Alert #{{ selectedStrCase.alertId }} &middot;
{{ selectedStrCase.cumulativeStrCaseCountForClient }} cumulative STR case(s) for this client
@if (selectedStrCase.cumulativeStrCaseCountForClient >= 3) {
<span class="escalation-badge">Risk category auto-escalated</span>
}
</p>
</div>
<button class="fiu-btn fiu-btn-g" (click)="closeStrCaseDetail()">Close</button>
</div>
<div class="case-form" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<div class="form-row">
<label for="fiuInvestigationNotes">Investigation notes (source-of-transaction history reviewed)</label>
<textarea id="fiuInvestigationNotes" rows="3" [(ngModel)]="concludeInvestigationNotes"></textarea>
</div>
<div class="form-row">
<label for="fiuSourceOfFundsNotes">Source-of-funds notes</label>
<textarea id="fiuSourceOfFundsNotes" rows="2" [(ngModel)]="concludeSourceOfFundsNotes"></textarea>
</div>
<div class="form-row conclusion-row">
<label>
<input type="radio" name="fiuConclusion" value="NOT_SUSPICIOUS" [(ngModel)]="concludeConclusion" />
Not suspicious (whitelist)
</label>
<label>
<input type="radio" name="fiuConclusion" value="SUSPICIOUS" [(ngModel)]="concludeConclusion" />
Suspicious (file STR)
</label>
</div>
<button class="fiu-btn fiu-btn-xml" (click)="concludeStrCase()">Conclude</button>
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
<div class="file-str-row">
<label for="fiuFileReferenceNo">FIU-IND acknowledgement / reference number</label>
<input id="fiuFileReferenceNo" class="fiu-inp" type="text" [(ngModel)]="fileReferenceNo" />
<button class="fiu-btn fiu-btn-xml" (click)="fileStrCase()">Mark STR Filed</button>
</div>
}
</div>
</div>
}
<!-- FINnet export template (separate concern — STR XML file-format generation) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>FINnet STR XML Export Template</h3>
<p>Detected by AML rules engine + branch compliance officers; filed within 7 days</p>
</div>
<button class="fiu-btn fiu-btn-xml" (click)="exportStrXml()">
+117
View File
@@ -579,6 +579,41 @@
background: var(--f-red-l);
color: var(--f-red);
}
&.status-raised {
background: var(--f-amber-l);
color: var(--f-amber);
}
&.status-under-review {
background: var(--f-blue-l);
color: var(--f-blue);
}
&.status-whitelisted,
&.status-concluded-clear,
&.status-generated,
&.status-submitted {
background: var(--f-green-l);
color: var(--f-green);
}
&.status-escalated,
&.status-open,
&.status-concluded-suspicious {
background: var(--f-red-l);
color: var(--f-red);
}
&.status-filed {
background: var(--f-purple-l);
color: var(--f-purple);
}
&.status-draft {
background: var(--f-g100);
color: var(--f-g500);
}
}
.risk-pill {
@@ -892,3 +927,85 @@
grid-template-columns: 1fr;
}
}
.selected-row {
background: var(--f-blue-l);
}
.escalation-warning {
color: var(--f-red);
font-weight: 700;
}
.escalation-badge {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
border-radius: 10px;
background: var(--f-amber-l);
color: var(--f-amber);
font-size: 11px;
font-weight: 600;
}
.str-case-detail {
border: 1px solid var(--f-g200);
border-radius: 10px;
padding: 16px;
background: #fff;
margin-bottom: 16px;
}
.case-form {
display: flex;
flex-direction: column;
gap: 12px;
textarea {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--f-g300);
font-family: inherit;
font-size: 13px;
}
}
.form-row {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 12px;
font-weight: 600;
color: var(--f-g500);
}
}
.conclusion-row {
flex-direction: row;
gap: 24px;
label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
}
}
.file-str-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding-top: 12px;
border-top: 1px dashed var(--f-g300);
label {
font-size: 12px;
font-weight: 600;
color: var(--f-g500);
}
}
+126 -2
View File
@@ -6,10 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { AmlService } from 'app/aml/aml.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import {
CtrTransaction,
CTR_TXNS,
@@ -33,7 +35,9 @@ import {
FormsModule
]
})
export class FiuComponent {
export class FiuComponent implements OnInit {
private amlService = inject(AmlService);
private authenticationService = inject(AuthenticationService);
/** Active tab */
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
@@ -57,6 +61,126 @@ export class FiuComponent {
toastVisible = false;
private toastTimer: any;
// -------------------- Live AML monitoring (real backend, replaces mock CTR/STR tables) --------------------
amlAlerts: any[] = [];
amlAlertStatusFilter = '';
amlReviewNotes: Record<number, string> = {};
amlCtrReports: any[] = [];
ctrGenerateMonth = '';
amlStrCases: any[] = [];
selectedStrCase: any = null;
concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS';
concludeInvestigationNotes = '';
concludeSourceOfFundsNotes = '';
fileReferenceNo = '';
get isPrincipalOfficer(): boolean {
return this.authenticationService.isPrincipalOfficerUser();
}
get isAmlOfficer(): boolean {
return this.authenticationService.isAmlOfficerUser();
}
ngOnInit(): void {
this.loadAmlAlerts();
this.loadAmlCtrReports();
this.loadAmlStrCases();
}
loadAmlAlerts(): void {
this.amlService
.getAlerts(this.amlAlertStatusFilter || undefined)
.subscribe((data) => (this.amlAlerts = data || []));
}
loadAmlCtrReports(): void {
this.amlService.getCtrReports().subscribe((data) => (this.amlCtrReports = data || []));
}
loadAmlStrCases(): void {
this.amlService.getStrCases().subscribe((data) => (this.amlStrCases = data || []));
}
whitelistAlert(alert: any): void {
this.amlService.reviewAlert(alert.id, 'WHITELIST', this.amlReviewNotes[alert.id] || '').subscribe(() => {
this.toast(`Alert #${alert.id} whitelisted`);
this.loadAmlAlerts();
});
}
escalateAlert(alert: any): void {
this.amlService.reviewAlert(alert.id, 'ESCALATE', this.amlReviewNotes[alert.id] || '').subscribe(() => {
this.toast(`Alert #${alert.id} escalated to Principal Officer`);
this.loadAmlAlerts();
this.loadAmlStrCases();
});
}
generateCtrReport(): void {
if (!this.ctrGenerateMonth) {
return;
}
this.amlService.generateCtrReport(this.ctrGenerateMonth).subscribe({
next: () => {
this.toast(`CTR report generated for ${this.ctrGenerateMonth}`);
this.loadAmlCtrReports();
},
error: (err) => this.toast(err?.error?.defaultUserMessage || 'CTR report generation failed')
});
}
selectStrCase(strCase: any): void {
this.selectedStrCase = strCase;
this.concludeConclusion = 'NOT_SUSPICIOUS';
this.concludeInvestigationNotes = strCase.investigationNotes || '';
this.concludeSourceOfFundsNotes = strCase.sourceOfFundsNotes || '';
this.fileReferenceNo = '';
}
closeStrCaseDetail(): void {
this.selectedStrCase = null;
}
concludeStrCase(): void {
if (!this.selectedStrCase) {
return;
}
this.amlService
.concludeStrCase(
this.selectedStrCase.id,
this.concludeConclusion,
this.concludeInvestigationNotes,
this.concludeSourceOfFundsNotes
)
.subscribe((updated) => {
this.toast(
this.concludeConclusion === 'SUSPICIOUS'
? `STR case #${updated.id} concluded suspicious — FIU due date ${updated.fiuDueDate}`
: `STR case #${updated.id} concluded not suspicious`
);
this.selectedStrCase = updated;
this.loadAmlStrCases();
});
}
fileStrCase(): void {
if (!this.selectedStrCase || !this.fileReferenceNo) {
return;
}
this.amlService.fileStr(this.selectedStrCase.id, this.fileReferenceNo).subscribe((updated) => {
this.toast(`STR case #${updated.id} recorded as filed (ref ${updated.strReferenceNo})`);
this.selectedStrCase = updated;
this.loadAmlStrCases();
});
}
amlStatusClass(status: string): string {
return 'status-pill status-' + (status || '').toLowerCase().replace(/_/g, '-');
}
showTab(tab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files'): void {
this.activeTab = tab;
}
@@ -1,285 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="ctr-str-dlg">
<!-- Dialog Header -->
<div class="dlg-header">
<div class="dlg-title-area">
<fa-icon icon="shield-alt" class="header-icon"></fa-icon>
<div>
<h2 class="title">CTR & STR Compliance Control Center</h2>
<p class="subtitle">Anti-Money Laundering (AML) & CFT Regulatory Reporting Terminal</p>
</div>
</div>
<button mat-icon-button [mat-dialog-close]="true" class="close-btn">
<mat-icon>close</mat-icon>
</button>
</div>
<!-- Tab Navigators -->
<div class="tab-navs">
<button class="tab-btn" [class.active]="activeTab === 'dashboard'" (click)="activeTab = 'dashboard'">
<fa-icon icon="chart-bar" class="tab-icon"></fa-icon> Compliance Dashboard
</button>
<button class="tab-btn" [class.active]="activeTab === 'guidelines'" (click)="activeTab = 'guidelines'">
<fa-icon icon="university" class="tab-icon"></fa-icon> RBI Regulatory Guidelines
</button>
</div>
<!-- Dialog Body (Tabs) -->
<div class="dlg-body">
<!-- TAB 1: COMPLIANCE DASHBOARD -->
@if (activeTab === 'dashboard') {
<div class="tab-content dashboard-tab">
<!-- SECTION 1: CTR MANAGMENT -->
<div class="dashboard-section card-glass">
<div class="section-hdr">
<div>
<h3>Cash Transaction Reports (CTR - Monthly Cash Registry)</h3>
<p class="section-sub">
Scans and compiles cash transactions exceeding INR 10 Lakhs limit (Single or monthly cumulative cash
deposits/withdrawals).
</p>
</div>
<button class="action-btn primary" [disabled]="isGeneratingCtr" (click)="generateCtrReport()">
@if (isGeneratingCtr) {
<span class="spinner"></span> Generating Report...
} @else {
<fa-icon icon="sync-alt"></fa-icon> Generate Monthly CTR
}
</button>
</div>
<div class="table-actions">
<input
type="text"
placeholder="Search CTR files by month or name..."
[(ngModel)]="ctrFilter"
class="search-input"
/>
</div>
<div class="table-container">
<table class="compliance-table">
<thead>
<tr>
<th>Report Payload Name</th>
<th>Reporting Period</th>
<th>Record Count</th>
<th>Generated By</th>
<th>Generated Date</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (report of filteredCtrReports; track report.id) {
<tr>
<td class="bold-text"><fa-icon icon="file-code" class="xml-icon"></fa-icon> {{ report.name }}</td>
<td>{{ report.month }}</td>
<td class="center-text">
<span class="cnt-badge">{{ report.recordCount }} txns</span>
</td>
<td>{{ report.generatedBy }}</td>
<td>{{ report.generatedAt | date: 'medium' }}</td>
<td><span class="status-badge ready">Ready</span></td>
<td>
<button
class="icon-action-btn"
matTooltip="Download FIU-IND XML Payload"
(click)="downloadCtrFile(report.name)"
>
<fa-icon icon="download"></fa-icon> Download XML
</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="empty-state">No CTR reports found matching your search.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- SECTION 2: STR ALERTS PIPELINE -->
<div class="dashboard-section card-glass mt-20">
<div class="section-hdr">
<div>
<h3>Suspicious Transaction Activity Pipeline (STR Management)</h3>
<p class="section-sub">
Behavioral & structural AML alerts generated by KYC profile rules engine. Investigator actions update
state in real-time.
</p>
</div>
<button class="action-btn secondary" (click)="downloadStrReportTemplate()">
<fa-icon icon="file-excel"></fa-icon> Export Active Alerts (CSV)
</button>
</div>
<div class="table-actions">
<input
type="text"
placeholder="Search alerts by Client, CIF, or alert type..."
[(ngModel)]="strFilter"
class="search-input"
/>
</div>
<div class="alerts-grid">
@for (alert of filteredStrAlerts; track alert.id) {
<div class="alert-card" [class]="alert.status.toLowerCase().replace(' ', '-')">
<div class="card-hdr">
<div class="client-info">
<h4>{{ alert.clientName }}</h4>
<span class="cif-number">{{ alert.clientCif }}</span>
</div>
<span class="status-badge" [class]="alert.status.toLowerCase().replace(' ', '-')">{{
alert.status
}}</span>
</div>
<div class="card-body">
<div class="amount-line">
<span class="label">Amount Highlighted:</span>
<span class="amount">₹{{ alert.amount | number: '1.2-2' }}</span>
</div>
<div class="type-line">
<span class="label">Alert Flag:</span>
<span class="type-value">{{ alert.alertType }}</span>
</div>
<p class="desc">{{ alert.description }}</p>
<span class="date-stamp"
><fa-icon icon="clock"></fa-icon> Flagged Date: {{ alert.date | date: 'mediumDate' }}</span
>
</div>
<div class="card-footer">
@if (alert.status === 'Open') {
<button class="card-btn investigate" (click)="changeAlertStatus(alert.id, 'Investigating')">
<fa-icon icon="search"></fa-icon> Investigate
</button>
}
@if (alert.status === 'Open' || alert.status === 'Investigating') {
<button class="card-btn file-str" (click)="changeAlertStatus(alert.id, 'Reported STR')">
<fa-icon icon="flag"></fa-icon> File STR
</button>
<button class="card-btn close-alert" (click)="changeAlertStatus(alert.id, 'Closed')">
<fa-icon icon="ban"></fa-icon> Dismiss
</button>
}
@if (alert.status === 'Closed' || alert.status === 'Reported STR') {
<button class="card-btn reset-alert" (click)="changeAlertStatus(alert.id, 'Open')">
<fa-icon icon="redo-alt"></fa-icon> Reopen Alert
</button>
}
</div>
</div>
} @empty {
<div class="empty-alerts">No Suspicious Transaction alerts matching your criteria.</div>
}
</div>
</div>
</div>
}
<!-- TAB 2: RBI REGULATORY GUIDELINES -->
@if (activeTab === 'guidelines') {
<div class="tab-content scroll-y">
<div class="info-layout">
<!-- CTR Rules Card -->
<div class="info-card card-glass">
<div class="card-title-badge ctr-badge-color">
<fa-icon icon="coins" class="badge-icon"></fa-icon> CTR - Cash Transaction Report
</div>
<div class="rules-body">
<p class="lead-desc">
As mandated by Section 12 of the **Prevention of Money Laundering Act (PMLA), 2002** and RBI KYC
guidelines, banks must record and report all high-value cash transactions.
</p>
<div class="rule-point">
<h5>1. Transaction Threshold</h5>
<p>
All cash transactions (either deposits or withdrawals) of **value more than INR 10 Lakhs** (or its
equivalent in foreign currency) in a single transaction.
</p>
</div>
<div class="rule-point">
<h5>2. Monthly Aggregation Rule (Structuring check)</h5>
<p>
A series of cash transactions in a calendar month that are **integrally connected** and accumulate to
a total value exceeding **INR 10 Lakhs** must be compiled and reported in the monthly CTR.
</p>
</div>
<div class="rule-point">
<h5>3. Reporting Timeline</h5>
<p>
CTRs must be generated and uploaded to the FIU-IND gateway **by the 15th day of the succeeding month**
(e.g., June transactions must be uploaded by July 15th).
</p>
</div>
<div class="rule-point text-warning-border">
<strong>Important Note:</strong> Transactions where the net cash balance does not change but high volume
is rotated (e.g., cash withdrawals immediately deposited back) must be reported under CTR as separate
debit and credit records.
</div>
</div>
</div>
<!-- STR Rules Card -->
<div class="info-card card-glass">
<div class="card-title-badge str-badge-color">
<fa-icon icon="flag" class="badge-icon"></fa-icon> STR - Suspicious Transaction Report
</div>
<div class="rules-body">
<p class="lead-desc">
Every reporting entity must maintain vigilance over account transactions. If there are reasonable
grounds to suspect transactions involve proceeds of crime, an STR must be generated.
</p>
<div class="rule-point">
<h5>1. What Constitutes Suspicion?</h5>
<p>
Transactions that are complex, show unusual patterns, mismatch the customer’s business/risk profile,
have no apparent economic rationale, or involve suspected proceeds of crime or terror funding.
</p>
</div>
<div class="rule-point">
<h5>2. No Minimum Threshold Limit</h5>
<p>
**There is no minimum monetary limit** for STR. An alert should be generated for any transaction,
regardless of size, if suspicious behavior is flagged.
</p>
</div>
<div class="rule-point">
<h5>3. Reporting Timeline</h5>
<p>
The STR must be filed to FIU-IND **within 7 days** of the Principal Compliance Officer confirming the
suspicion.
</p>
</div>
<div class="rule-point text-danger-border">
<strong>Strict Tipping-off Restriction:</strong> It is illegal to disclose or "tip-off" to the client
that their account is being monitored, investigated, or reported under an STR. Complete confidentiality
must be maintained.
</div>
</div>
</div>
</div>
</div>
}
</div>
</div>
@@ -1,750 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.ctr-str-dlg {
display: flex;
flex-direction: column;
height: 90vh;
max-height: 820px;
background: #f8fafc;
color: #1e293b;
font-family: Inter, sans-serif;
overflow: hidden;
}
// Header
.dlg-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
.dlg-title-area {
display: flex;
align-items: center;
gap: 14px;
.header-icon {
font-size: 26px;
color: #4f46e5;
}
.title {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #0f172a;
}
.subtitle {
margin: 2px 0 0;
font-size: 12px;
color: #64748b;
}
}
.close-btn {
color: #64748b;
&:hover {
color: #0f172a;
}
}
}
// Navigation Tabs
.tab-navs {
display: flex;
gap: 6px;
background: #fff;
padding: 8px 24px;
border-bottom: 1px solid #e2e8f0;
.tab-btn {
display: flex;
align-items: center;
gap: 8px;
background: transparent;
border: none;
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
color: #64748b;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s ease;
.tab-icon {
font-size: 14px;
}
&:hover {
background: #f1f5f9;
color: #0f172a;
}
&.active {
background: #e0e7ff;
color: #4f46e5;
}
}
}
// Scroll layout
.scroll-y {
overflow-y: auto;
flex: 1;
}
// Dialog Body
.dlg-body {
flex: 1;
overflow: hidden;
padding: 24px;
background: #f1f5f9;
display: flex;
flex-direction: column;
}
.tab-content {
height: 100%;
display: flex;
flex-direction: column;
&.scroll-y {
overflow-y: auto;
padding-right: 6px;
/* Custom scrollbar */
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
&:hover {
background: #94a3b8;
}
}
}
}
// Dashboard Tab specific layout
.dashboard-tab {
overflow-y: auto;
padding-right: 6px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
}
// Card Style Container
.card-glass {
background: #fff;
border-radius: 12px;
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 5%),
0 2px 4px -1px rgb(0 0 0 / 2%);
border: 1px solid #e2e8f0;
padding: 20px;
}
.dashboard-section {
display: flex;
flex-direction: column;
.section-hdr {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h3 {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
.section-sub {
margin: 4px 0 0;
font-size: 12px;
color: #64748b;
}
}
}
.mt-20 {
margin-top: 20px;
}
.mt-25 {
margin-top: 25px;
}
// Search input
.table-actions {
margin-bottom: 14px;
.search-input {
width: 100%;
max-width: 380px;
padding: 8px 12px;
font-size: 13px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
color: #1e293b;
outline: none;
&:focus {
border-color: #4f46e5;
box-shadow: 0 0 0 2px rgb(79 70 229 / 10%);
}
}
}
// Button styles
.action-btn {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
border-radius: 6px;
padding: 10px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
&.primary {
background: #4f46e5;
color: #fff;
&:hover:not([disabled]) {
background: #4338ca;
}
&:disabled {
background: #94a3b8;
cursor: not-allowed;
}
}
&.secondary {
background: #0ea5e9;
color: #fff;
&:hover {
background: #0284c7;
}
}
}
// Spinner animation
.spinner {
width: 12px;
height: 12px;
border: 2px solid rgb(255 255 255 / 30%);
border-radius: 50%;
border-top-color: #fff;
animation: spin 0.8s linear infinite;
display: inline-block;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// Tables styling
.table-container {
overflow-x: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.compliance-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
text-align: left;
th {
background: #f8fafc;
color: #475569;
font-weight: 600;
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
}
td {
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
color: #334155;
&.bold-text {
font-weight: 600;
color: #0f172a;
}
&.center-text {
text-align: center;
}
}
tr:last-child td {
border-bottom: none;
}
.cnt-badge {
background: #e2e8f0;
color: #475569;
padding: 3px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
.xml-icon {
color: #ea580c;
margin-right: 4px;
}
.empty-state {
text-align: center;
color: #64748b;
padding: 24px;
font-style: italic;
}
}
// Icon Action Button
.icon-action-btn {
background: #f1f5f9;
border: 1px solid #cbd5e1;
color: #475569;
border-radius: 4px;
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #e2e8f0;
color: #0f172a;
}
}
// Alert Cards Grid
.alerts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.alert-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 5%);
}
// Left boundary color codes based on status
&.open {
border-left: 4px solid #dc2626;
}
&.investigating {
border-left: 4px solid #ea580c;
}
&.reported-str {
border-left: 4px solid #16a34a;
}
&.closed {
border-left: 4px solid #64748b;
}
.card-hdr {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
h4 {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
.cif-number {
font-size: 11px;
color: #64748b;
}
}
.card-body {
flex: 1;
font-size: 12px;
color: #475569;
.amount-line,
.type-line {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
.label {
color: #64748b;
}
.amount {
font-weight: 700;
color: #0f172a;
}
.type-value {
font-weight: 600;
color: #4f46e5;
}
}
.desc {
margin: 8px 0;
line-height: 1.4;
background: #f8fafc;
padding: 8px;
border-radius: 4px;
border: 1px dashed #e2e8f0;
font-style: italic;
}
.date-stamp {
font-size: 11px;
color: #94a3b8;
display: block;
margin-top: 8px;
}
}
.card-footer {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 14px;
border-top: 1px solid #f1f5f9;
padding-top: 12px;
.card-btn {
flex: 1;
min-width: 75px;
padding: 6px 8px;
font-size: 11px;
font-weight: 600;
border: none;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
transition: all 0.2s ease;
&.investigate {
background: #fef3c7;
color: #d97706;
&:hover {
background: #fde68a;
}
}
&.file-str {
background: #dcfce7;
color: #16a34a;
&:hover {
background: #bbf7d0;
}
}
&.close-alert {
background: #fee2e2;
color: #dc2626;
&:hover {
background: #fecaca;
}
}
&.reset-alert {
background: #f1f5f9;
color: #475569;
border: 1px solid #cbd5e1;
&:hover {
background: #e2e8f0;
}
}
}
}
}
// Badges
.status-badge {
padding: 3px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 600;
&.ready {
background: #dcfce7;
color: #16a34a;
}
&.open {
background: #fee2e2;
color: #dc2626;
}
&.investigating {
background: #fef3c7;
color: #d97706;
}
&.reported-str {
background: #dbeafe;
color: #2563eb;
}
&.closed {
background: #f1f5f9;
color: #475569;
}
}
.empty-alerts {
grid-column: 1 / -1;
text-align: center;
padding: 36px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
color: #64748b;
font-style: italic;
font-size: 13px;
}
// Tab 2: Guidelines layout
.info-layout {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
@media (width >= 900px) {
grid-template-columns: 1fr 1fr;
}
}
.info-card {
display: flex;
flex-direction: column;
.card-title-badge {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 700;
margin-bottom: 16px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #fff;
}
.ctr-badge-color {
background: #ea580c;
}
.str-badge-color {
background: #be185d;
}
.rules-body {
font-size: 13px;
line-height: 1.5;
color: #334155;
.lead-desc {
font-size: 14px;
color: #0f172a;
margin-bottom: 20px;
}
.rule-point {
margin-bottom: 18px;
h5 {
margin: 0 0 6px;
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
p {
margin: 0;
color: #475569;
}
}
.text-warning-border {
border-left: 3px solid #eab308;
background: #fef9c3;
padding: 10px 14px;
border-radius: 0 6px 6px 0;
color: #854d0e;
}
.text-danger-border {
border-left: 3px solid #ef4444;
background: #fee2e2;
padding: 10px 14px;
border-radius: 0 6px 6px 0;
color: #991b1b;
}
}
}
// Tab 3: Architecture & Schema
.arch-container {
display: flex;
flex-direction: column;
h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.desc {
margin: 4px 0 20px;
font-size: 12px;
color: #64748b;
}
}
.arch-section {
h4 {
margin: 0 0 4px;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
.section-sub {
margin: 0 0 10px;
font-size: 12px;
color: #64748b;
}
}
.code-snippet {
background: #0f172a;
color: #e2e8f0;
padding: 14px;
border-radius: 8px;
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
overflow-x: auto;
line-height: 1.4;
margin: 0;
&.plain {
background: #1e293b;
color: #94a3b8;
}
}
// API Routes
.route-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 8px;
font-size: 12px;
.method {
padding: 3px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
color: #fff;
min-width: 45px;
text-align: center;
&.get {
background: #16a34a;
}
&.put {
background: #ea580c;
}
&.post {
background: #2563eb;
}
}
.path {
font-family: 'Courier New', Courier, monospace;
font-weight: 700;
color: #0f172a;
}
.route-desc {
color: #475569;
flex: 1;
}
}
@@ -1,279 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatTabsModule } from '@angular/material/tabs';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatIconModule } from '@angular/material/icon';
import { FaIconComponent, FaIconLibrary } from '@fortawesome/angular-fontawesome';
import {
faShieldAlt,
faChartBar,
faUniversity,
faCode,
faSyncAlt,
faFileExcel,
faFileCode,
faDownload,
faClock,
faSearch,
faFlag,
faBan,
faRedoAlt,
faCoins
} from '@fortawesome/free-solid-svg-icons';
interface CtrReport {
id: number;
name: string;
month: string;
recordCount: number;
generatedBy: string;
generatedAt: Date;
status: 'Ready' | 'Generating';
}
interface StrAlert {
id: number;
clientName: string;
clientCif: string;
alertType: string;
amount: number;
status: 'Open' | 'Investigating' | 'Reported STR' | 'Closed';
description: string;
date: Date;
}
@Component({
selector: 'mifosx-ctr-str-dialog',
standalone: true,
templateUrl: './ctr-str-dialog.component.html',
styleUrls: ['./ctr-str-dialog.component.scss'],
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatTabsModule,
MatTooltipModule,
MatIconModule,
FaIconComponent
]
})
export class CtrStrDialogComponent {
dialogRef = inject(MatDialogRef<CtrStrDialogComponent>);
constructor() {
const library = inject(FaIconLibrary);
library.addIcons(
faShieldAlt,
faChartBar,
faUniversity,
faCode,
faSyncAlt,
faFileExcel,
faFileCode,
faDownload,
faClock,
faSearch,
faFlag,
faBan,
faRedoAlt,
faCoins
);
}
activeTab: 'dashboard' | 'guidelines' = 'dashboard';
// Mock State
isGeneratingCtr = false;
ctrFilter = '';
strFilter = '';
ctrReports: CtrReport[] = [
{
id: 1,
name: 'CTR_REPORT_JUNE_2026.xml',
month: 'June 2026',
recordCount: 14,
generatedBy: 'Compliance_Officer_1',
generatedAt: new Date(2026, 6, 5, 10, 30),
status: 'Ready'
},
{
id: 2,
name: 'CTR_REPORT_MAY_2026.xml',
month: 'May 2026',
recordCount: 22,
generatedBy: 'Compliance_Officer_1',
generatedAt: new Date(2026, 5, 4, 14, 15),
status: 'Ready'
},
{
id: 3,
name: 'CTR_REPORT_APRIL_2026.xml',
month: 'April 2026',
recordCount: 9,
generatedBy: 'Compliance_Officer_2',
generatedAt: new Date(2026, 4, 3, 11, 45),
status: 'Ready'
}
];
strAlerts: StrAlert[] = [
{
id: 101,
clientName: 'Aarav Sharma',
clientCif: 'CIF982348',
alertType: 'Profile Mismatch',
amount: 450000,
status: 'Open',
description:
'Student account received a large inward transfer (INR 4.5L) mismatching declared zero/student income category.',
date: new Date(2026, 6, 26)
},
{
id: 102,
clientName: 'Karan Malhotra',
clientCif: 'CIF229482',
alertType: 'Structuring (Smurfing)',
amount: 980000,
status: 'Investigating',
description:
'Multiple cash deposits of INR 2.45L, 2.45L, 2.45L, 2.45L within 10 days, totaling INR 9.8L cash (deliberately staying below the 10L single cash transaction CTR threshold).',
date: new Date(2026, 6, 24)
},
{
id: 103,
clientName: 'Nexora Real Estate Ltd',
clientCif: 'CIF500481',
alertType: 'Turnover Outlier',
amount: 15000000,
status: 'Open',
description:
'Entity account registered an outward wire of INR 1.5Cr when maximum monthly turnover limit is set to INR 50L.',
date: new Date(2026, 6, 20)
},
{
id: 104,
clientName: 'Priyanka Patel',
clientCif: 'CIF774920',
alertType: 'Dormant Account Reactivation',
amount: 600000,
status: 'Reported STR',
description:
'Account inactive for 18 months suddenly received a cash deposit of INR 6L, followed by immediate online transfers.',
date: new Date(2026, 6, 15)
}
];
get filteredCtrReports() {
return this.ctrReports.filter(
(r) =>
r.name.toLowerCase().includes(this.ctrFilter.toLowerCase()) ||
r.month.toLowerCase().includes(this.ctrFilter.toLowerCase())
);
}
get filteredStrAlerts() {
return this.strAlerts.filter(
(a) =>
a.clientName.toLowerCase().includes(this.strFilter.toLowerCase()) ||
a.clientCif.toLowerCase().includes(this.strFilter.toLowerCase()) ||
a.alertType.toLowerCase().includes(this.strFilter.toLowerCase())
);
}
// Actions
generateCtrReport() {
if (this.isGeneratingCtr) {
return;
}
this.isGeneratingCtr = true;
// Simulate API call to Spring Boot batch job
setTimeout(() => {
const date = new Date();
const newReport: CtrReport = {
id: this.ctrReports.length + 1,
name: `CTR_REPORT_JULY_2026_DRAFT.xml`,
month: 'July 2026 (Draft)',
recordCount: 8,
generatedBy: 'Current_Admin',
generatedAt: date,
status: 'Ready'
};
this.ctrReports.unshift(newReport);
this.isGeneratingCtr = false;
}, 2000);
}
downloadCtrFile(reportName: string) {
const dummyXml = `<?xml version="1.0" encoding="UTF-8"?>
<FIU-IND-CTR-Report xmlns="http://fiuindia.gov.in/ctr/v1">
<Header>
<ReportType>CTR</ReportType>
<ReportingEntityName>CBS Nexora Bank</ReportingEntityName>
<ReportingPeriod>2026-06</ReportingPeriod>
</Header>
<ReportData>
<Record>
<TransactionID>TXN88924824</TransactionID>
<Date>2026-06-15</Date>
<Mode>CASH</Mode>
<Amount>1250000.00</Amount>
<AccountType>SAVINGS</AccountType>
<CustomerName>Aarav Sharma</CustomerName>
<CifNumber>CIF982348</CifNumber>
</Record>
</ReportData>
</FIU-IND-CTR-Report>`;
const blob = new Blob([dummyXml], { type: 'application/xml' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = reportName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
changeAlertStatus(alertId: number, newStatus: 'Open' | 'Investigating' | 'Reported STR' | 'Closed') {
const alert = this.strAlerts.find((a) => a.id === alertId);
if (alert) {
alert.status = newStatus;
}
}
downloadStrReportTemplate() {
const dummyCsv =
`AlertID,ClientName,CIF,AlertType,Amount,Status,Description\n` +
this.strAlerts
.map(
(a) =>
`"${a.id}","${a.clientName}","${a.clientCif}","${a.alertType}","${a.amount}","${a.status}","${a.description.replace(/"/g, '""')}"`
)
.join('\n');
const blob = new Blob([dummyCsv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `STR_AML_ALERTS_EXPORT.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
}
@@ -0,0 +1,87 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container aml-rules-container">
<mat-card class="aml-card">
<mat-card-header class="aml-card-header">
<div class="aml-header-content">
<mat-icon class="header-icon">shield</mat-icon>
<div>
<mat-card-title class="header-title">AML Rules Master</mat-card-title>
<mat-card-subtitle class="header-subtitle">
Database-driven rules for client risk categorization and AML compliance checks
</mat-card-subtitle>
</div>
</div>
</mat-card-header>
<mat-card-content class="aml-card-content">
<table mat-table [dataSource]="rules" class="aml-table mat-elevation-z1">
<!-- Order Position Column -->
<ng-container matColumnDef="orderPosition">
<th mat-header-cell *matHeaderCellDef class="col-center">Rule Srno</th>
<td mat-cell *matCellDef="let element" class="col-center">{{ element.orderPosition }}</td>
</ng-container>
<!-- Rule Name Column -->
<ng-container matColumnDef="ruleName">
<th mat-header-cell *matHeaderCellDef>Rule Description</th>
<td mat-cell *matCellDef="let element" class="rule-name-cell">{{ element.ruleName }}</td>
</ng-container>
<!-- Risk Category Column -->
<ng-container matColumnDef="riskCategory">
<th mat-header-cell *matHeaderCellDef class="col-center">Risk Category</th>
<td mat-cell *matCellDef="let element" class="col-center">
<span class="risk-badge" [ngClass]="getRiskColorClass(element.riskCategory)">
<span class="dot"></span>
{{ getRiskCategoryLabel(element.riskCategory) }}
</span>
</td>
</ng-container>
<!-- Reason Column -->
<ng-container matColumnDef="reason">
<th mat-header-cell *matHeaderCellDef>Rule Reason / Criteria</th>
<td mat-cell *matCellDef="let element" class="reason-cell">{{ element.reason }}</td>
</ng-container>
<!-- AML Check Status Column -->
<ng-container matColumnDef="amlCheckStatus">
<th mat-header-cell *matHeaderCellDef class="col-center">AML Check Status</th>
<td mat-cell *matCellDef="let element" class="col-center">
<span class="compliance-badge" [ngClass]="'status-' + (element.amlCheckStatus || 'clear').toLowerCase()">
{{ element.amlCheckStatus || 'CLEAR' }}
</span>
</td>
</ng-container>
<!-- Banned Entity Match Column -->
<ng-container matColumnDef="bannedEntityMatch">
<th mat-header-cell *matHeaderCellDef class="col-center">Banned Match</th>
<td mat-cell *matCellDef="let element" class="col-center">
<span class="compliance-badge" [ngClass]="'match-' + (element.bannedEntityMatch || 'no').toLowerCase()">
{{ element.bannedEntityMatch || 'NO' }}
</span>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="rule-row"></tr>
</table>
<!-- Mat Paginator for standard pagination -->
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons class="aml-paginator"></mat-paginator>
<div *ngIf="rules.data.length === 0" class="no-rules-message">
<mat-icon>info_outline</mat-icon>
<span>No AML rules found in the database.</span>
</div>
</mat-card-content>
</mat-card>
</div>
@@ -0,0 +1,228 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.aml-rules-container {
padding: 24px;
background-color: var(--bg-neutral-light, #f8fafc);
min-height: calc(100vh - 64px);
}
.aml-card {
border-radius: 12px;
box-shadow:
0 4px 20px -2px rgb(0 0 0 / 5%),
0 2px 8px -1px rgb(0 0 0 / 3%);
border: 1px solid rgb(226 232 240 / 80%);
background: #fff;
overflow: hidden;
}
.aml-card-header {
background: linear-gradient(135deg, #1e293b, #0f172a);
color: #fff;
padding: 24px;
}
.aml-header-content {
display: flex;
align-items: center;
gap: 16px;
.header-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: #38bdf8;
}
.header-title {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.02em;
color: #fff;
margin-bottom: 4px;
}
.header-subtitle {
font-size: 13px;
color: #94a3b8;
}
}
.aml-card-content {
padding: 24px;
}
.aml-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e2e8f0;
th {
background-color: #f1f5f9;
color: #475569;
font-weight: 600;
font-size: 13px;
padding: 16px;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid #cbd5e1;
}
td {
padding: 16px;
font-size: 14px;
color: #334155;
border-bottom: 1px solid #e2e8f0;
}
.rule-row {
transition: background-color 0.2s ease;
&:hover {
background-color: #f8fafc;
}
&:last-child td {
border-bottom: none;
}
}
}
.col-center {
text-align: center;
justify-content: center;
}
.rule-name-cell {
font-weight: 500;
color: #1e293b;
max-width: 300px;
}
.reason-cell {
color: #64748b;
font-size: 13px;
max-width: 400px;
}
/* Badges for Risk Category */
.risk-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
&.risk-high {
background-color: #fef2f2;
color: #ef4444;
border: 1px solid #fee2e2;
.dot {
background-color: #ef4444;
}
}
&.risk-medium {
background-color: #fffbeb;
color: #d97706;
border: 1px solid #fef3c7;
.dot {
background-color: #d97706;
}
}
&.risk-low {
background-color: #f0fdf4;
color: #16a34a;
border: 1px solid #dcfce7;
.dot {
background-color: #16a34a;
}
}
}
/* Badges for Compliance status */
.compliance-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
/* AML Statuses */
&.status-clear {
background-color: #dcfce7;
color: #15803d;
}
&.status-flagged {
background-color: #fef3c7;
color: #b45309;
}
&.status-blocked {
background-color: #fee2e2;
color: #b91c1c;
}
/* Banned Matches */
&.match-no {
background-color: #f1f5f9;
color: #475569;
}
&.match-yes {
background-color: #fee2e2;
color: #b91c1c;
}
&.match-partial_match {
background-color: #ffedd5;
color: #c2410c;
}
}
.no-rules-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: #64748b;
gap: 12px;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
}
}
.aml-paginator {
background: #fff;
border-top: 1px solid #e2e8f0;
padding: 8px 0;
font-size: 13px;
color: #475569;
}
@@ -0,0 +1,87 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject, ViewChild } from '@angular/core';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
import { CommonModule } from '@angular/common';
/** Custom Services */
import { ClientsService } from '../../clients/clients.service';
@Component({
selector: 'mifosx-aml-rules',
templateUrl: './aml-rules.component.html',
styleUrls: ['./aml-rules.component.scss'],
standalone: true,
imports: [
CommonModule,
MatTableModule,
MatCardModule,
MatIconModule,
MatButtonModule,
MatCheckboxModule,
MatPaginatorModule
]
})
export class AmlRulesComponent implements OnInit {
private clientsService = inject(ClientsService);
rules = new MatTableDataSource<any>([]);
displayedColumns: string[] = [
'orderPosition',
'ruleName',
'riskCategory',
'reason',
'amlCheckStatus',
'bannedEntityMatch'
];
@ViewChild(MatPaginator) paginator!: MatPaginator;
ngOnInit(): void {
this.clientsService.getClientRiskRules().subscribe({
next: (data) => {
this.rules.data = data || [];
this.rules.paginator = this.paginator;
},
error: () => {}
});
}
getRiskCategoryLabel(category: number): string {
switch (category) {
case 1:
return 'Low Risk';
case 2:
return 'Medium Risk';
case 3:
return 'High Risk';
default:
return 'Unknown';
}
}
getRiskColorClass(category: number): string {
switch (category) {
case 1:
return 'risk-low';
case 2:
return 'risk-medium';
case 3:
return 'risk-high';
default:
return '';
}
}
}
@@ -11,6 +11,7 @@ import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { RbiCircularsComponent } from './rbi-circulars.component';
import { AmlRulesComponent } from './aml-rules/aml-rules.component';
const routes: Routes = [
Route.withShell([
@@ -18,6 +19,11 @@ const routes: Routes = [
path: 'rbi-circulars',
component: RbiCircularsComponent,
data: { title: 'RBI Circulars', breadcrumb: 'RBI Circulars' }
},
{
path: 'rbi/aml',
component: AmlRulesComponent,
data: { title: 'AML Rules Master', breadcrumb: 'AML Rules Master' }
}
])
];