feat: add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
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-11 11:11:18 +05:30
parent 01e86173c1
commit 7b895a0e2b
77 changed files with 10046 additions and 1130 deletions
+1
View File
@@ -16,6 +16,7 @@
"build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none",
"build:prod": "node version.js && node --max-old-space-size=16384 ./node_modules/@angular/cli/bin/ng build --configuration production --output-hashing=none --base-href=/web-app/",
"start": "npm run env -s && ng serve --proxy-config proxy.localhost.conf.js",
"start:share": "npm run env -s && ng serve --host 0.0.0.0 --proxy-config proxy.localhost.conf.js",
"start:demo": "npm run env -s && ng serve --proxy-config proxy.conf.js",
"serve:dev": "npm run env -s && ng serve --source-map --proxy-config proxy.localhost.conf.js",
"serve:sw": "npm run build -s && npx http-server ./dist -p 4200",
@@ -1,70 +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/.
-->
<h2 class="mk-title" [class.mk-title--str]="isEscalation">
<fa-icon [icon]="isEscalation ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
{{ isEscalation ? 'Mark as Suspicious' : 'Mark as Whitelisted' }}
</h2>
<mat-dialog-content>
<div class="mk-entry">
<div>
<label>Journal entry</label><span class="mono">#{{ data.journalEntryId }}</span>
</div>
<div>
<label>Transaction</label><span class="mono">{{ data.transactionId }}</span>
</div>
<div>
<label>Date</label><span>{{ data.transactionDate }}</span>
</div>
<div>
<label>Amount</label><span class="mono">&#8377;{{ data.amount | number: '1.2-2' }}</span>
</div>
<div class="wide">
<label>GL account</label><span>{{ data.glAccountName }}</span>
</div>
</div>
@if (isEscalation) {
<p class="mk-warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
This raises an AML alert against the customer behind this entry and escalates it. An STR case opens for the
Principal Officer with the seven working day FIU-IND clock running. It cannot be undone from this screen.
</p>
} @else {
<p class="mk-note">
Records the entry as reviewed and cleared. No report is filed and the customer's risk rating is untouched.
</p>
}
<label class="mk-field">
<span>Reviewer notes {{ isEscalation ? '(required)' : '(optional)' }}</span>
<textarea
rows="4"
[(ngModel)]="notes"
[placeholder]="
isEscalation
? 'Why is this entry suspicious? What did you check?'
: 'What satisfied you that this entry is in order?'
"
></textarea>
</label>
</mat-dialog-content>
<mat-dialog-actions class="mk-actions">
<button class="mk-btn" (click)="dialogRef.close()">Cancel</button>
<button
class="mk-btn"
[class.mk-btn--str]="isEscalation"
[class.mk-btn--ok]="!isEscalation"
[disabled]="!canSubmit"
(click)="confirm()"
>
{{ isEscalation ? 'Escalate to STR' : 'Whitelist entry' }}
</button>
</mat-dialog-actions>
@@ -1,165 +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/.
*/
:host {
--mk-g100: #f1f5f9;
--mk-g200: #e2e8f0;
--mk-g300: #cbd5e1;
--mk-g500: #64748b;
--mk-g700: #334155;
--mk-g900: #0f172a;
--mk-green: #15803d;
--mk-red: #c0392b;
--mk-amber-l: #fef3c7;
display: block;
font-family: 'DM Sans', system-ui, sans-serif;
color: var(--mk-g900);
min-width: 460px;
}
.mk-title {
display: flex;
align-items: center;
gap: 9px;
margin: 0;
padding: 18px 22px 10px;
font-size: 16px;
font-weight: 700;
color: var(--mk-green);
&--str {
color: var(--mk-red);
}
}
.mk-entry {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 18px;
padding: 11px 14px;
background: #fff;
border: 1px solid var(--mk-g200);
border-radius: 8px;
margin-bottom: 12px;
> div {
display: flex;
flex-direction: column;
}
.wide {
grid-column: span 2;
}
label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mk-g500);
}
span {
font-size: 12.5px;
}
}
.mk-warn {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
color: #78350f;
background: var(--mk-amber-l);
border-radius: 7px;
padding: 9px 12px;
margin: 0 0 12px;
}
.mk-note {
font-size: 12px;
color: var(--mk-g500);
margin: 0 0 12px;
}
.mk-field {
display: flex;
flex-direction: column;
gap: 4px;
span {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mk-g500);
}
textarea {
padding: 9px 11px;
border: 1px solid var(--mk-g300);
border-radius: 7px;
font-size: 13px;
font-family: inherit;
resize: vertical;
&:focus {
outline: none;
border-color: var(--mk-g700);
}
}
}
.mk-actions {
display: flex;
justify-content: flex-end;
gap: 9px;
padding: 8px 22px 18px;
}
.mk-btn {
padding: 8px 16px;
border: 1px solid var(--mk-g300);
background: #fff;
border-radius: 8px;
font-size: 12.5px;
font-weight: 600;
color: var(--mk-g700);
cursor: pointer;
font-family: inherit;
&:hover:not(:disabled) {
background: var(--mk-g100);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--ok {
background: var(--mk-green);
border-color: var(--mk-green);
color: #fff;
&:hover:not(:disabled) {
background: #12692f;
}
}
&--str {
background: var(--mk-red);
border-color: var(--mk-red);
color: #fff;
&:hover:not(:disabled) {
background: #a5301f;
}
}
}
@@ -1,63 +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/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
export interface MarkEntryDialogData {
disposition: 'WHITELIST' | 'ESCALATE';
journalEntryId: number;
transactionId: string;
amount: number;
glAccountName: string;
transactionDate: string;
}
/**
* Captures the reviewer's reason before a ledger entry is marked.
*
* Notes are not optional on an escalation: the Principal Officer picks the case up
* with nothing else to go on, and an STR filed on an unexplained referral is not
* defensible. A whitelist may be recorded without them.
*/
@Component({
selector: 'mifosx-mark-entry-dialog',
templateUrl: './mark-entry-dialog.component.html',
styleUrls: ['./mark-entry-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent,
MatDialogContent,
MatDialogActions
]
})
export class MarkEntryDialogComponent {
dialogRef = inject<MatDialogRef<MarkEntryDialogComponent>>(MatDialogRef);
data = inject<MarkEntryDialogData>(MAT_DIALOG_DATA);
notes = '';
get isEscalation(): boolean {
return this.data.disposition === 'ESCALATE';
}
get canSubmit(): boolean {
return !this.isEscalation || this.notes.trim().length > 0;
}
confirm(): void {
if (this.canSubmit) {
this.dialogRef.close(this.notes.trim());
}
}
}
@@ -535,14 +535,6 @@
</div>
</div>
@if (markError) {
<div class="gl-mark-error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>{{ markError }}</span>
<button class="gl-mark-error__close" (click)="markError = ''">&times;</button>
</div>
}
<div class="gl-table-shell">
<mat-table [dataSource]="dataSource" matSort class="gl-mat-table">
<!-- #ID -->
@@ -700,50 +692,6 @@
</mat-cell>
</ng-container>
<!-- AML review — whitelist or refer the entry to STR -->
<ng-container matColumnDef="amlMark">
<mat-header-cell *matHeaderCellDef class="gl-th">AML Review</mat-header-cell>
<mat-cell *matCellDef="let e" class="gl-td">
@if (markOf(e); as mark) {
<span
class="gl-mark"
[class.gl-mark--str]="mark.disposition === 'ESCALATE'"
[matTooltip]="
(mark.disposition === 'ESCALATE'
? 'Escalated to STR' + (mark.strCaseId ? ' — case #' + mark.strCaseId : '')
: 'Whitelisted') +
(mark.reviewedBy ? ' by ' + mark.reviewedBy : '') +
(mark.notes ? ' — ' + mark.notes : '')
"
>
<fa-icon [icon]="mark.disposition === 'ESCALATE' ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
{{ mark.disposition === 'ESCALATE' ? 'Suspicious' : 'Whitelisted' }}
</span>
} @else if (isMarking(e)) {
<span class="gl-mark gl-mark--busy">Saving…</span>
} @else if (canMark(e)) {
<span class="gl-mark-actions">
<button
class="gl-mark-btn gl-mark-btn--ok"
matTooltip="Reviewed and in order — clear this entry"
(click)="openMarkDialog(e, 'WHITELIST')"
>
<fa-icon icon="check-circle"></fa-icon> Whitelist
</button>
<button
class="gl-mark-btn gl-mark-btn--str"
matTooltip="Refer to the Principal Officer as an STR case"
(click)="openMarkDialog(e, 'ESCALATE')"
>
<fa-icon icon="exclamation-triangle"></fa-icon> Suspicious
</button>
</span>
} @else {
<span class="gl-mark gl-mark--na" [matTooltip]="markBlockedReason(e)">—</span>
}
</mat-cell>
</ng-container>
<!-- Actions -->
<ng-container matColumnDef="actions">
<mat-header-cell *matHeaderCellDef class="gl-th"></mat-header-cell>
@@ -1328,17 +1328,6 @@
flex: 0 0 130px;
}
// Wide enough to hold both action buttons on one line. Every other column
// declares a width; without one this collapsed and clipped its contents.
::ng-deep .mat-column-amlMark {
width: 184px;
min-width: 184px;
flex: 0 0 184px;
// Keeps the "AML REVIEW" heading on one line instead of clipping it.
white-space: nowrap;
overflow: visible;
}
::ng-deep .mat-column-actions {
width: 50px;
min-width: 50px;
@@ -1567,112 +1556,6 @@
}
}
// ── AML review column ─────────────────────────────────────────────────────────
// Never wrap — a wrapped second button used to be clipped by the row height.
.gl-mark-actions {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: nowrap;
width: 100%;
}
.gl-mark-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 4px 8px;
border: 1px solid var(--c-border);
background: var(--c-surface);
border-radius: 6px;
font-size: 0.62rem;
font-weight: 700;
line-height: 1.2;
color: var(--c-text-2);
cursor: pointer;
font-family: inherit;
white-space: nowrap;
flex: 1 1 auto;
transition: all var(--transition);
// Tinted at rest, not only on hover — these have to be findable in a dense
// table without the reviewer hunting for them.
&--ok {
border-color: rgb(16 185 129 / 45%);
background: var(--c-cr-bg);
color: #047857;
&:hover {
border-color: var(--c-cr);
background: #d1fae5;
}
}
&--str {
border-color: rgb(239 68 68 / 45%);
background: var(--c-dr-bg);
color: #b91c1c;
&:hover {
border-color: var(--c-dr);
background: #fee2e2;
}
}
}
.gl-mark {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 9px;
border-radius: 999px;
font-size: 0.62rem;
font-weight: 700;
white-space: nowrap;
background: var(--c-cr-bg);
color: #047857;
&--str {
background: var(--c-dr-bg);
color: #b91c1c;
}
&--busy {
background: #f1f5f9;
color: var(--c-text-3);
}
&--na {
background: transparent;
color: var(--c-text-3);
cursor: help;
}
}
.gl-mark-error {
display: flex;
align-items: center;
gap: 9px;
margin: 0 0 10px;
padding: 9px 14px;
border: 1px solid #fecaca;
background: var(--c-dr-bg);
border-radius: 8px;
font-size: 0.75rem;
color: #b91c1c;
&__close {
margin-left: auto;
border: 0;
background: transparent;
font-size: 1.1rem;
line-height: 1;
color: inherit;
cursor: pointer;
}
}
// ── Empty state ───────────────────────────────────────────────────────────────
.gl-empty {
display: flex;
@@ -41,20 +41,8 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatCardModule } from '@angular/material/card';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatIconModule } from '@angular/material/icon';
import { MatDialog } from '@angular/material/dialog';
import { AmlService } from 'app/aml/aml.service';
import { MarkEntryDialogComponent, MarkEntryDialogData } from './mark-entry-dialog.component';
const STATE_KEY = 'gl_ledger_filter_state';
/** How a ledger entry has been marked by a reviewer, keyed on journal entry id. */
interface EntryMark {
disposition: 'WHITELIST' | 'ESCALATE';
strCaseId: number | null;
reviewedBy: string;
notes: string;
}
@Component({
selector: 'mifosx-search-journal-entry',
standalone: true,
@@ -98,8 +86,6 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private route = inject(ActivatedRoute);
private amlService = inject(AmlService);
private dialog = inject(MatDialog);
// ── Misc ──────────────────────────────────────────────────────────────────
minDate = new Date(2000, 0, 1);
@@ -142,7 +128,6 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
'manualEntry',
'createdByUserName',
'submittedOnDate',
'amlMark',
'actions'
];
dayWiseColumns = [
@@ -171,14 +156,6 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
dayWiseData: any[] = [];
glAccountInfo: any = null;
// ── AML marking ───────────────────────────────────────────────────────────
/** Marks already recorded, by journal entry id. */
private marks = new Map<number, EntryMark>();
/** Entry ids with a request in flight, so a row cannot be double-submitted. */
private marking = new Set<number>();
markError = '';
// ── UI state ──────────────────────────────────────────────────────────────
activeQuickFilter = '';
activeFiltersCount = 0;
@@ -234,103 +211,6 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
this.setFilteredOffices();
this.getJournalEntries();
this.loadLedgerSummary();
this.loadMarks();
}
// ── AML marking ───────────────────────────────────────────────────────────
/**
* Marks are loaded once for the whole ledger rather than per page, because the
* user pages and re-filters constantly and the set is small — only entries a
* reviewer has actually touched.
*/
private loadMarks(): void {
this.amlService.getManualReferrals().subscribe({
next: (rows) => {
this.marks = new Map(
(rows || []).map((r: any) => [
r.journalEntryId,
{
disposition: r.disposition === 'ESCALATE' ? 'ESCALATE' : 'WHITELIST',
strCaseId: r.strCaseId ?? null,
reviewedBy: r.reviewedByUsername || '',
notes: r.reviewNotes || ''
} as EntryMark
])
);
},
// A ledger that cannot reach the AML service still has to render.
error: () => (this.marks = new Map())
});
}
markOf(entry: any): EntryMark | null {
return this.marks.get(entry?.id) ?? null;
}
isMarking(entry: any): boolean {
return this.marking.has(entry?.id);
}
/**
* An alert needs a customer, and a journal entry only reaches one through the
* savings, loan or client transaction that produced it. A purely manual GL
* posting reaches none, so it cannot be referred — the server enforces this too.
*/
canMark(entry: any): boolean {
return !!entry && !this.markOf(entry) && !!(entry.savingsAccountNo || entry.entityId);
}
markBlockedReason(entry: any): string {
if (this.markOf(entry)) {
return 'Already reviewed — a ledger entry is marked once';
}
return 'No customer behind this entry, so it cannot be referred';
}
openMarkDialog(entry: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
if (!this.canMark(entry) || this.isMarking(entry)) {
return;
}
const data: MarkEntryDialogData = {
disposition,
journalEntryId: entry.id,
transactionId: entry.transactionId,
amount: entry.amount,
glAccountName: `${entry.glAccountCode} — ${entry.glAccountName}`,
transactionDate: this.dateUtils.formatDate(entry.transactionDate, this.settingsService.dateFormat)
};
this.dialog
.open(MarkEntryDialogComponent, { data, width: '520px', autoFocus: false })
.afterClosed()
.subscribe((notes: string | undefined) => {
if (notes !== undefined) {
this.submitMark(entry, disposition, notes);
}
});
}
private submitMark(entry: any, disposition: 'WHITELIST' | 'ESCALATE', notes: string): void {
this.markError = '';
this.marking.add(entry.id);
this.amlService.referJournalEntry(entry.id, disposition, notes).subscribe({
next: (res: any) => {
this.marking.delete(entry.id);
this.marks.set(entry.id, {
disposition,
strCaseId: res?.strCaseId ?? null,
reviewedBy: res?.reviewedByUsername || '',
notes
});
},
error: (err: any) => {
this.marking.delete(entry.id);
this.markError =
err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || 'Could not mark this entry.';
}
});
}
ngOnDestroy() {
+24
View File
@@ -34,6 +34,30 @@ export class AmlService {
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
}
// ═══ Recurring-alert review queue ═══
/**
* Customers whose transactions repeatedly trip the engine. Each row carries the triggering
* alerts, so the reviewer can see why the customer keeps surfacing.
*/
getRecurringAlerts(minAlerts: number, windowDays: number, onlyNeedingReview: boolean): Observable<any[]> {
const params = new HttpParams()
.set('minAlerts', String(minAlerts))
.set('windowDays', String(windowDays))
.set('onlyNeedingReview', String(onlyNeedingReview));
return this.http.get<any[]>('/aml/recurring-alerts', { params });
}
/** outcome: 'CLEARED' | 'MONITOR' | 'ESCALATED' */
reviewRecurringAlert(
clientId: number,
outcome: 'CLEARED' | 'MONITOR' | 'ESCALATED',
notes: string,
windowDays: number
): Observable<any> {
return this.http.post(`/aml/recurring-alerts/${clientId}/review`, { outcome, notes, windowDays });
}
// ═══ Manual referrals from the general ledger ═══
/**
@@ -1750,6 +1750,38 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
return salutation ? `${salutation} ${full}` : full;
}
/**
* The name PAN and Aadhaar are actually matched against.
*
* Deliberately not {@link fullNamePreview}: that prefixes the salutation, while
* ClientKycWritePlatformServiceJpaRepositoryImpl builds the verification name from firstname +
* middlename + lastname only. Values are passed through untouched rather than capitalised, so what
* the KYC step displays is exactly what goes to the provider.
*/
get verificationName(): string {
return [
this.createClientForm?.get('firstname')?.value,
this.createClientForm?.get('middlename')?.value,
this.createClientForm?.get('lastname')?.value
]
.map((part: any) => (part || '').toString().trim())
.filter(Boolean)
.join(' ');
}
/** Date of birth as captured, sent to the provider formatted dd/MM/yyyy. */
get verificationDateOfBirth(): Date | null {
const value = this.createClientForm?.get('dateOfBirth')?.value;
if (!value) {
return null;
}
if (value instanceof Date) {
return value;
}
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
getSelectedGender(): any {
const val = this.createClientForm.get('genderId')?.value;
return this.genderOptions?.find((g: any) => g.id === val) || null;
@@ -38,10 +38,17 @@ import { ClientsService } from 'app/clients/clients.service';
}
</div>
<p class="sanctions-msg">
Screening <strong>{{ clientName }}</strong> against<br />
<strong>UN Security Council</strong> &amp;
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
Screening <strong>{{ clientName }}</strong> against the<br />
<strong>UN Security Council Consolidated List</strong><br />
<span class="sanctions-lists"
>ISIL (Da&rsquo;esh) &amp; Al-Qaida (1267/1989/2253) and Taliban (1988) sanctions lists</span
>
</p>
@if (listDataAsOf) {
<p class="list-as-of">
Sanctions list data as of <strong>{{ listDataAsOf }}</strong>
</p>
}
<p class="regulatory-note">{{ regulatoryBasis }}</p>
</div>
}
@@ -54,8 +61,8 @@ import { ClientsService } from 'app/clients/clients.service';
<h3 class="approve-title">No Sanctions Match Found</h3>
<p class="approve-detail">
<strong>{{ clientName }}</strong> has been screened against the
<strong>UN Security Council Consolidated List</strong> and <strong>FIU-IND</strong> records. No confirmed
match was found.
<strong>UN Security Council Consolidated List</strong> (ISIL &amp; Al-Qaida and Taliban sanctions lists). No
confirmed match was found.
</p>
<div class="approve-meta">
<div class="meta-row">
@@ -222,6 +229,20 @@ import { ClientsService } from 'app/clients/clients.service';
text-align: center;
}
.list-as-of {
font-size: 12px;
color: #374151;
text-align: center;
margin: 4px 0 0;
}
.sanctions-lists {
display: inline-block;
margin-top: 4px;
font-size: 12px;
color: #6b7280;
}
.regulatory-note {
font-size: 11px;
color: #9ca3af;
@@ -550,16 +571,24 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
screeningLogId: number | null = null;
listDataAsOf: string | null = null;
/**
* Cites the direction actually in force. Chapter IX ("Requirements/obligations under International
* Agreements") is the clause that puts the UNSC lists in scope, and the 2025 Directions superseded
* the older Master Direction this screen used to name.
*/
regulatoryBasis =
'Screened as per Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and the RBI Master Direction on KYC.';
'Screened under Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and Chapter IX of the ' +
'RBI Know Your Customer Directions, 2025 (amended as on December 29, 2025).';
screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
progressValue = 0;
currentStep = 0;
// Steps describe the screening that actually runs against the UNSC consolidated list; there is no
// FIU-IND lookup behind this call, so it is no longer claimed here.
progressSteps = [
{ label: 'Connecting to UN Security Council database...', icon: 'globe' as any },
{ label: 'Screening name against consolidated list...', icon: 'search' as any },
{ label: 'Cross-checking FIU-IND records...', icon: 'database' as any },
{ label: 'Cross-checking listed aliases and entities...', icon: 'database' as any },
{ label: 'Analyzing results...', icon: 'chart-bar' as any }
];
@@ -577,6 +606,9 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
next: (result: any) => {
this.apiResult = result;
this.apiDone = true;
// Surfaced while the screening is still running, so the operator can see which snapshot of
// the UNSC list the check is being made against rather than only learning it afterwards.
this.listDataAsOf = this.formatListDate(result?.listDataAsOf);
},
error: (err: any) => {
this.apiError = err;
@@ -652,13 +684,7 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
'Unable to connect to sanctions screening service.';
} else {
this.screeningLogId = this.apiResult?.screeningLogId || null;
this.listDataAsOf = this.apiResult?.listDataAsOf
? new Date(this.apiResult.listDataAsOf).toLocaleDateString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric'
})
: null;
this.listDataAsOf = this.formatListDate(this.apiResult?.listDataAsOf);
if (this.apiResult?.matchFound) {
this.matchFound = true;
this.matchCount = this.apiResult.matchCount || 0;
@@ -668,6 +694,18 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
}, 400);
}
/** Formats the date the UNSC snapshot was loaded; null when the backend did not report one. */
private formatListDate(value: string | null | undefined): string | null {
if (!value) {
return null;
}
const parsed = new Date(value);
if (isNaN(parsed.getTime())) {
return null;
}
return parsed.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
}
ngOnDestroy() {
clearInterval(this.progressTimer);
}
@@ -56,6 +56,42 @@
}
}
<!-- What PAN / Aadhaar are matched against. Shown before the officer clicks Verify, because
the provider matches on exactly these two values and returns nothing identifiable when
they disagree — a blind check that fails is much harder to act on than a visible one. -->
@if (!isNonIndividual) {
<div
class="identity-basis full-width"
[class.identity-basis--incomplete]="!verificationName || !verificationDateOfBirth"
>
<mat-icon class="identity-basis__icon">badge</mat-icon>
<div class="identity-basis__body">
<span class="identity-basis__legend">Documents are verified against the General tab</span>
<div class="identity-basis__pairs">
<span class="identity-basis__pair">
<span class="identity-basis__k">Name</span>
<span class="identity-basis__v">{{ verificationName || 'Not entered' }}</span>
</span>
<span class="identity-basis__pair">
<span class="identity-basis__k">Date of Birth</span>
<span class="identity-basis__v">
{{ verificationDateOfBirth ? (verificationDateOfBirth | date: 'dd MMM yyyy') : 'Not entered' }}
</span>
</span>
</div>
@if (!verificationName || !verificationDateOfBirth) {
<span class="identity-basis__warn">
Both are required for PAN and Aadhaar verification — complete them on the General tab first.
</span>
} @else {
<span class="identity-basis__hint">
Must match the card exactly. Salutation is excluded; a mismatch in either fails verification.
</span>
}
</div>
</div>
}
<!-- Individual PAN with inline verify -->
@if (!isMinor && kycForm.get('hasPan')?.value) {
<div class="form-field verify-field">
@@ -105,8 +141,109 @@
@if (panVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
@if (panVerifyStatus !== 'VERIFIED' && panDeclarationTaken) {
<span class="verify-chip declared"><fa-icon icon="file-signature"></fa-icon> Declaration</span>
}
</div>
</div>
@if (panVerifyStatus === 'VERIFIED') {
<div
class="pal-card full-width"
[class.pal-card--ok]="panAadhaarSeedingStatus === 'Y'"
[class.pal-card--fail]="panAadhaarSeedingStatus === 'N'"
[class.pal-card--info]="panAadhaarSeedingStatus === 'E'"
[class.pal-card--pending]="
panAadhaarSeedingStatus !== 'Y' && panAadhaarSeedingStatus !== 'N' && panAadhaarSeedingStatus !== 'E'
"
style="margin-bottom: 16px"
>
<div class="pal-card__left">
<div class="pal-card__icon">
<mat-icon>credit_card</mat-icon>
</div>
<div class="pal-card__txt">
<div class="pal-card__small">Verification Status</div>
<div class="pal-card__title">PAN &harr; Aadhaar</div>
<div class="pal-card__status">
@if (panAadhaarSeedingStatus === 'Y') {
<mat-icon>check_circle</mat-icon> Linked (Seeded)
} @else if (panAadhaarSeedingStatus === 'N') {
<mat-icon>cancel</mat-icon> Not Linked
} @else if (panAadhaarSeedingStatus === 'E') {
<mat-icon>info</mat-icon> Exempt
} @else {
<mat-icon>radio_button_unchecked</mat-icon> Pending
}
</div>
</div>
</div>
<div class="pal-card__badge">
@if (panAadhaarSeedingStatus === 'Y') {
<mat-icon>check</mat-icon>
} @else if (panAadhaarSeedingStatus === 'N') {
<mat-icon>close</mat-icon>
} @else if (panAadhaarSeedingStatus === 'E') {
<mat-icon>info</mat-icon>
} @else {
<mat-icon>schedule</mat-icon>
}
</div>
<div class="pal-card__hint">As per Income Tax Portal &mdash; Section 139AA</div>
</div>
}
<!--
PAN could not be verified but a declaration is on file. Shown in full because it is the
only thing standing between this client and a PAN KYC exception — an officer reviewing
the record has to be able to see what was declared, not just that something was.
-->
@if (panVerifyStatus !== 'VERIFIED' && panDeclarationTaken) {
<div class="pan-decl-card">
<div class="pan-decl-card__head">
<mat-icon class="pan-decl-card__icon">assignment_turned_in</mat-icon>
<span class="pan-decl-card__title">PAN Declaration Taken</span>
@if (panDeclarationTakenOn) {
<span class="pan-decl-card__date">{{ panDeclarationTakenOn }}</span>
}
</div>
<div class="pan-decl-card__rows">
<div class="pan-decl-card__row">
<span class="k">Does not match</span>
<span class="v strong">{{ panDeclarationMismatchLabel }}</span>
</div>
@if (panDeclaration?.nameOnPan) {
<div class="pan-decl-card__row">
<span class="k">Name on PAN</span>
<span class="v">{{ panDeclaration?.nameOnPan }}</span>
</div>
}
@if (panDeclaration?.dobOnPan) {
<div class="pan-decl-card__row">
<span class="k">DOB on PAN</span>
<span class="v">{{ panDeclaration?.dobOnPan | date: 'dd MMMM yyyy' }}</span>
</div>
}
@if (panDeclaration?.fileName) {
<div class="pan-decl-card__row">
<span class="k">Declaration file</span>
<span class="v">{{ panDeclaration?.fileName }}</span>
</div>
}
</div>
<div class="pan-decl-card__note">
PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file.
</div>
@if (!readOnly) {
<div class="pan-decl-card__actions">
<button mat-stroked-button type="button" (click)="openPanDeclaration()">Edit Declaration</button>
<button mat-stroked-button type="button" class="danger" (click)="removePanDeclaration()">
Remove
</button>
</div>
}
</div>
}
}
<!-- Aadhaar with independent verify (Individual only) -->
@@ -175,9 +312,68 @@
@if (aadhaarVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
@if (aadhaarVerifyStatus !== 'VERIFIED' && aadhaarReceiptTaken) {
<span class="verify-chip declared"><fa-icon icon="receipt"></fa-icon> Receipt</span>
}
</div>
</div>
<!--
A customer who has applied for Aadhaar but not received it has no number to verify, so no
amount of retrying the OTP will clear the check. Offer the enrolment slip instead rather
than leaving the officer unable to finish the client.
-->
@if (aadhaarVerifyStatus !== 'VERIFIED' && !aadhaarReceiptTaken && !aadhaarOtpSent && !readOnly) {
<div class="aad-rcpt-offer full-width">
<mat-icon>receipt_long</mat-icon>
<span>Customer has applied for Aadhaar but not received it yet?</span>
<button mat-stroked-button type="button" (click)="openAadhaarReceipt()">Record Enrolment Receipt</button>
</div>
}
<!--
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
only thing standing between this client and an Aadhaar KYC exception.
-->
@if (aadhaarVerifyStatus !== 'VERIFIED' && aadhaarReceiptTaken) {
<div class="aad-rcpt-card full-width">
<div class="aad-rcpt-card__head">
<mat-icon class="aad-rcpt-card__icon">receipt_long</mat-icon>
<span class="aad-rcpt-card__title">Aadhaar Enrolment Receipt Recorded</span>
@if (aadhaarReceiptTakenOn) {
<span class="aad-rcpt-card__date">{{ aadhaarReceiptTakenOn }}</span>
}
</div>
<div class="aad-rcpt-card__rows">
<div class="aad-rcpt-card__row">
<span class="k">Enrolment ID</span>
<span class="v mono">{{ aadhaarReceipt?.enrolmentId }}</span>
</div>
@if (aadhaarReceipt?.enrolmentDate) {
<div class="aad-rcpt-card__row">
<span class="k">Enrolment date</span>
<span class="v">{{ aadhaarReceipt?.enrolmentDate | date: 'dd MMMM yyyy' }}</span>
</div>
}
@if (aadhaarReceipt?.remarks) {
<div class="aad-rcpt-card__row">
<span class="k">Remarks</span>
<span class="v">{{ aadhaarReceipt?.remarks }}</span>
</div>
}
</div>
<div class="aad-rcpt-card__note">
Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file.
</div>
@if (!readOnly) {
<div class="aad-rcpt-card__actions">
<button mat-stroked-button type="button" (click)="openAadhaarReceipt()">Edit Receipt</button>
<button mat-stroked-button type="button" class="danger" (click)="removeAadhaarReceipt()">Remove</button>
</div>
}
</div>
}
<!-- Aadhaar OTP Section -->
@if (aadhaarOtpSent && aadhaarVerifyStatus !== 'VERIFIED') {
<div class="otp-card full-width">
@@ -725,51 +921,53 @@
}
<!-- KYC Exception Flag -->
<div
class="exception-flag-box"
style="
margin-top: 20px;
padding: 16px;
border: 1px solid #f59e0b;
background: #fffbeb;
border-radius: 8px;
clear: both;
"
>
<h5
@if (!kycAllDone) {
<div
class="exception-flag-box"
style="
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #b45309;
display: flex;
align-items: center;
gap: 8px;
margin-top: 20px;
padding: 16px;
border: 1px solid #f59e0b;
background: #fffbeb;
border-radius: 8px;
clear: both;
"
>
<fa-icon icon="exclamation-triangle"></fa-icon>
KYC Exception Flag
</h5>
<div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap">
<mat-checkbox formControlName="isException">Mark as KYC Exception</mat-checkbox>
<h5
style="
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #b45309;
display: flex;
align-items: center;
gap: 8px;
"
>
<fa-icon icon="exclamation-triangle"></fa-icon>
KYC Exception Flag
</h5>
<div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap">
<mat-checkbox formControlName="isException">Mark as KYC Exception</mat-checkbox>
@if (kycForm.get('isException')?.value) {
<mat-form-field appearance="outline" style="flex: 1; min-width: 280px; margin-bottom: -1.25em">
<mat-label>Exception Reasons</mat-label>
<mat-select formControlName="exceptionReason" multiple>
@if (!isNonIndividual) {
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
} @else {
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
}
</mat-select>
</mat-form-field>
}
@if (kycForm.get('isException')?.value) {
<mat-form-field appearance="outline" style="flex: 1; min-width: 280px; margin-bottom: -1.25em">
<mat-label>Exception Reasons</mat-label>
<mat-select formControlName="exceptionReason" multiple>
@if (!isNonIndividual) {
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
} @else {
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
</div>
</div>
}
</div>
<!-- ═══ SECTION: KYC Document Management ═══ -->
@@ -847,19 +1045,23 @@
<!-- Existing saved ID-proof doc — locked by default. Replace flow
unlocks automatically when the section-level Edit button toggles
`readOnly` off. -->
@if (existingKycDocuments.length > 0 && !kycDocFiles.front && readOnly) {
@if (existingKycDocSides.front && !kycDocFiles.front && readOnly) {
<div class="kyc-doc-dropzone kyc-doc-dropzone--has kyc-doc-dropzone--locked">
<div class="kyc-doc-dropzone__existing">
@if (kycDocMergedPreview) {
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
@if (existingKycDocSides.front?.previewUrl) {
<img
[src]="existingKycDocSides.front?.previewUrl"
alt="Saved front side"
class="kyc-doc-dropzone__thumb"
/>
} @else {
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
}
<div class="kyc-doc-dropzone__existing-meta">
<span class="kyc-doc-dropzone__existing-name">
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
{{ existingKycDocSides.front?.fileName }}
</span>
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc()">
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc('front')">
<mat-icon>visibility</mat-icon>
Click here to view
</button>
@@ -869,8 +1071,8 @@
} @else {
<label
class="kyc-doc-dropzone"
[class.kyc-doc-dropzone--has]="kycDocFiles.front || existingKycDocuments.length > 0"
[class.kyc-doc-dropzone--req]="!kycDocFiles.front && existingKycDocuments.length === 0"
[class.kyc-doc-dropzone--has]="kycDocFiles.front || existingKycDocSides.front"
[class.kyc-doc-dropzone--req]="!kycDocFiles.front && !existingKycDocSides.front"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'front')" />
@if (kycDocFiles.front) {
@@ -886,24 +1088,28 @@
<mat-icon>close</mat-icon>
</button>
</div>
} @else if (existingKycDocuments.length > 0) {
} @else if (existingKycDocSides.front) {
<!-- Edit mode WITH existing doc — let the user view it OR
click anywhere to replace. -->
<div class="kyc-doc-dropzone__existing">
@if (kycDocMergedPreview) {
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
@if (existingKycDocSides.front?.previewUrl) {
<img
[src]="existingKycDocSides.front?.previewUrl"
alt="Saved front side"
class="kyc-doc-dropzone__thumb"
/>
} @else {
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
}
<div class="kyc-doc-dropzone__existing-meta">
<span class="kyc-doc-dropzone__existing-name">
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
{{ existingKycDocSides.front?.fileName }}
</span>
<div class="kyc-doc-dropzone__existing-actions">
<button
type="button"
class="kyc-doc-dropzone__view"
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc()"
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc('front')"
>
<mat-icon>visibility</mat-icon>
View
@@ -930,25 +1136,45 @@
<div class="kyc-doc-upload-slot">
<div class="kyc-doc-upload-head">
<span class="kyc-doc-upload-title"> <mat-icon>image</mat-icon> Back side </span>
<!-- Hide Required pill when saved combined doc covers both sides -->
@if (!(existingKycDocuments.length > 0 && !kycDocFiles.back)) {
<!-- Hide Required pill when a back scan (or a stacked scan holding it) is on file -->
@if (!((existingKycDocSides.back || kycDocIsLegacyCombined) && !kycDocFiles.back)) {
<span class="kyc-doc-upload-req">Required</span>
}
</div>
<!-- Locked view: saved combined doc covers the back side too. -->
@if (existingKycDocuments.length > 0 && !kycDocFiles.back && readOnly) {
<!--
A stacked scan that could not be cut in two has no separate back image. Say so rather
than repeating the front slot's picture here.
-->
@if (kycDocIsLegacyCombined && !kycDocFiles.back) {
<div class="kyc-doc-dropzone kyc-doc-dropzone--has kyc-doc-dropzone--locked">
<div class="kyc-doc-dropzone__existing">
@if (kycDocMergedPreview) {
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
<mat-icon class="kyc-doc-dropzone__doc-ic">filter_none</mat-icon>
<div class="kyc-doc-dropzone__existing-meta">
<span class="kyc-doc-dropzone__existing-name">Included in the combined scan</span>
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc('front')">
<mat-icon>visibility</mat-icon>
View both sides
</button>
</div>
</div>
</div>
} @else if (existingKycDocSides.back && !kycDocFiles.back && readOnly) {
<div class="kyc-doc-dropzone kyc-doc-dropzone--has kyc-doc-dropzone--locked">
<div class="kyc-doc-dropzone__existing">
@if (existingKycDocSides.back?.previewUrl) {
<img
[src]="existingKycDocSides.back?.previewUrl"
alt="Saved back side"
class="kyc-doc-dropzone__thumb"
/>
} @else {
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
}
<div class="kyc-doc-dropzone__existing-meta">
<span class="kyc-doc-dropzone__existing-name">
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
{{ existingKycDocSides.back?.fileName }}
</span>
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc()">
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc('back')">
<mat-icon>visibility</mat-icon>
Click here to view
</button>
@@ -958,8 +1184,8 @@
} @else {
<label
class="kyc-doc-dropzone"
[class.kyc-doc-dropzone--has]="kycDocFiles.back || existingKycDocuments.length > 0"
[class.kyc-doc-dropzone--req]="!kycDocFiles.back && existingKycDocuments.length === 0"
[class.kyc-doc-dropzone--has]="kycDocFiles.back || existingKycDocSides.back"
[class.kyc-doc-dropzone--req]="!kycDocFiles.back && !existingKycDocSides.back"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'back')" />
@if (kycDocFiles.back) {
@@ -975,23 +1201,27 @@
<mat-icon>close</mat-icon>
</button>
</div>
} @else if (existingKycDocuments.length > 0) {
<!-- Edit mode WITH existing combined doc — view + replace. -->
} @else if (existingKycDocSides.back) {
<!-- Edit mode WITH an existing back scan — view + replace. -->
<div class="kyc-doc-dropzone__existing">
@if (kycDocMergedPreview) {
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
@if (existingKycDocSides.back?.previewUrl) {
<img
[src]="existingKycDocSides.back?.previewUrl"
alt="Saved back side"
class="kyc-doc-dropzone__thumb"
/>
} @else {
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
}
<div class="kyc-doc-dropzone__existing-meta">
<span class="kyc-doc-dropzone__existing-name">
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
{{ existingKycDocSides.back?.fileName }}
</span>
<div class="kyc-doc-dropzone__existing-actions">
<button
type="button"
class="kyc-doc-dropzone__view"
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc()"
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc('back')"
>
<mat-icon>visibility</mat-icon>
View
@@ -1027,7 +1257,9 @@
<mat-icon>filter_none</mat-icon>
Combined preview (front + back)
</span>
<span class="kyc-merged__hint">Saved as a single PNG. Re-crop a side to update the merged file.</span>
<span class="kyc-merged__hint">
Preview only — each side is saved as its own file. Re-crop a side to update it.
</span>
</div>
<div class="kyc-merged__preview">
<img [src]="kycDocMergedPreview" alt="Merged KYC document" />
@@ -1197,27 +1429,36 @@
</div>
}
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>{{ isKycVerified ? 'Reason for Re-edit *' : 'Remarks' }}</mat-label>
<textarea
matInput
formControlName="remarks"
rows="2"
[placeholder]="isKycVerified ? 'Mandatory — explain why KYC is being re-edited' : ''"
></textarea>
@if (isKycVerified && !kycForm.get('remarks')?.value?.trim()) {
<mat-error>Remarks are mandatory when updating a verified KYC record</mat-error>
}
</mat-form-field>
@if (isKycVerified) {
<div class="kyc-reedit-alert">
<mat-icon class="kyc-reedit-alert__icon">info</mat-icon>
<span
>KYC is <strong>VERIFIED</strong>. Any update will reset the status to <strong>IN PROGRESS</strong> and
require PAN re-verification.</span
>
</div>
@if (kycForm.get('allowRiskOverride')?.value || isKycVerified) {
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>
{{
isKycVerified
? 'Reason for Re-edit'
: kycForm.get('allowRiskOverride')?.value
? 'Remarks (Required for Risk Override)'
: 'Remarks'
}}
</mat-label>
<textarea
matInput
formControlName="remarks"
rows="2"
[placeholder]="
isKycVerified
? 'Mandatory — explain why KYC is being re-edited'
: kycForm.get('allowRiskOverride')?.value
? 'Mandatory — explain why you are overriding the auto-calculated risk category'
: ''
"
></textarea>
@if ((isKycVerified || kycForm.get('allowRiskOverride')?.value) && !kycForm.get('remarks')?.value?.trim()) {
<mat-error>
Remarks are mandatory when
{{ isKycVerified ? 'updating a verified KYC record' : 'overriding the risk category' }}
</mat-error>
}
</mat-form-field>
}
</div>
@@ -1393,6 +1634,39 @@
<mat-option value="REGULATORY">Regulatory Authority</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field" (click)="freezeDatePicker.open()">
<mat-label>Freeze Date</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>calendar_today</mat-icon></span>
<input matInput required [max]="today" [matDatepicker]="freezeDatePicker" formControlName="freezeDate" />
<mat-datepicker-toggle matSuffix [for]="freezeDatePicker"></mat-datepicker-toggle>
<mat-datepicker #freezeDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Freeze Amount</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>currency_rupee</mat-icon></span>
<input matInput type="number" min="0" formControlName="freezeAmount" />
<mat-hint>Leave blank for a full freeze</mat-hint>
</mat-form-field>
}
@if (kycForm.get('accountStatus')?.value === 'UNFREEZE') {
<mat-form-field appearance="outline" class="form-field" (click)="unfreezeDatePicker.open()">
<mat-label>Unfreeze Date</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>lock_open</mat-icon></span>
<input matInput required [max]="today" [matDatepicker]="unfreezeDatePicker" formControlName="unfreezeDate" />
<mat-datepicker-toggle matSuffix [for]="unfreezeDatePicker"></mat-datepicker-toggle>
<mat-datepicker #unfreezeDatePicker></mat-datepicker>
</mat-form-field>
}
@if (kycForm.get('accountStatus')?.value === 'NO_DEBIT') {
<mat-form-field appearance="outline" class="form-field" (click)="noDebitDatePicker.open()">
<mat-label>No Debit From Date</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>money_off</mat-icon></span>
<input matInput required [matDatepicker]="noDebitDatePicker" formControlName="noDebitDate" />
<mat-datepicker-toggle matSuffix [for]="noDebitDatePicker"></mat-datepicker-toggle>
<mat-datepicker #noDebitDatePicker></mat-datepicker>
</mat-form-field>
}
</div>
</div>
@@ -1545,4 +1819,34 @@
</div>
</div>
</div>
<!-- Form Validation Summary -->
@if (kycForm && kycForm.invalid) {
<div
class="kyc-validation-summary"
style="
margin-top: 24px;
padding: 16px;
background: #fff5f5;
border: 1px solid #fee2e2;
border-radius: 12px;
display: flex;
align-items: flex-start;
gap: 12px;
clear: both;
"
>
<mat-icon style="color: #ef4444; flex-shrink: 0">error_outline</mat-icon>
<div style="display: flex; flex-direction: column; gap: 4px">
<span style="font-size: 14px; font-weight: 600; color: #991b1b"
>Please complete/correct the following fields to proceed:</span
>
<ul style="margin: 4px 0 0 16px; padding: 0; font-size: 13px; color: #b91c1c; line-height: 1.5">
@for (label of invalidFieldLabels; track label) {
<li>{{ label }}</li>
}
</ul>
</div>
</div>
}
</div>
@@ -304,6 +304,206 @@
color: #059669;
border: 1px solid #a7f3d0;
}
// Not verified, but covered by a declaration — deliberately amber rather than green so it never
// reads as a passing check.
&.declared {
background: #fffbeb;
color: #b45309;
border: 1px solid #fcd34d;
}
}
// ── PAN declaration summary ────────────────────────────────────────────────
.pan-decl-card {
margin: -4px 0 16px;
padding: 12px 14px;
border: 1px solid #fcd34d;
border-radius: 8px;
background: #fffbeb;
}
.pan-decl-card__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.pan-decl-card__icon {
color: #b45309;
font-size: 18px;
width: 18px;
height: 18px;
}
.pan-decl-card__title {
font-size: 13px;
font-weight: 600;
color: #92400e;
}
.pan-decl-card__date {
margin-left: auto;
font-size: 11px;
color: #b45309;
}
.pan-decl-card__rows {
display: flex;
flex-direction: column;
gap: 4px;
}
.pan-decl-card__row {
display: grid;
grid-template-columns: 130px 1fr;
gap: 10px;
font-size: 12.5px;
.k {
color: #92400e;
font-weight: 500;
}
.v {
color: #451a03;
word-break: break-word;
&.strong {
font-weight: 600;
}
}
}
.pan-decl-card__note {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid #fde68a;
font-size: 11.5px;
line-height: 1.5;
color: #b45309;
}
.pan-decl-card__actions {
display: flex;
gap: 8px;
margin-top: 10px;
.danger {
color: #b91c1c;
}
}
// ── Aadhaar enrolment receipt ──────────────────────────────────────────────
// Blue rather than the declaration's amber: nothing has failed here, the customer simply does not
// hold the document yet.
.aad-rcpt-offer {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin: -4px 0 16px;
padding: 10px 14px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
font-size: 12.5px;
color: #1e40af;
mat-icon {
color: #2563eb;
font-size: 18px;
width: 18px;
height: 18px;
}
button {
margin-left: auto;
color: #1d4ed8 !important;
border-color: #bfdbfe !important;
}
}
.aad-rcpt-card {
margin: -4px 0 16px;
padding: 12px 14px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
}
.aad-rcpt-card__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.aad-rcpt-card__icon {
color: #2563eb;
font-size: 18px;
width: 18px;
height: 18px;
}
.aad-rcpt-card__title {
font-size: 13px;
font-weight: 600;
color: #1e3a8a;
}
.aad-rcpt-card__date {
margin-left: auto;
font-size: 11px;
color: #2563eb;
}
.aad-rcpt-card__rows {
display: flex;
flex-direction: column;
gap: 4px;
}
.aad-rcpt-card__row {
display: grid;
grid-template-columns: 130px 1fr;
gap: 10px;
font-size: 12.5px;
.k {
color: #1e40af;
font-weight: 500;
}
.v {
color: #0f172a;
word-break: break-word;
&.mono {
font-family: monospace;
letter-spacing: 0.02em;
}
}
}
.aad-rcpt-card__note {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid #bfdbfe;
font-size: 11.5px;
line-height: 1.5;
color: #2563eb;
}
.aad-rcpt-card__actions {
display: flex;
gap: 8px;
margin-top: 10px;
.danger {
color: #b91c1c;
}
}
.verify-icon-ok {
@@ -2431,3 +2631,358 @@
}
}
}
/* The name and DOB that PAN / Aadhaar are matched against, surfaced from the General tab so a
mismatch is visible before the check runs rather than only after it fails. */
.identity-basis {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 11px 14px;
margin-bottom: 4px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
}
.identity-basis--incomplete {
border-color: #fdba74;
background: #fff7ed;
}
.identity-basis__icon {
flex: 0 0 auto;
font-size: 20px;
width: 20px;
height: 20px;
color: #1d4ed8;
.identity-basis--incomplete & {
color: #c2410c;
}
}
.identity-basis__body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.identity-basis__legend {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.identity-basis__pairs {
display: flex;
flex-wrap: wrap;
gap: 6px 24px;
}
.identity-basis__pair {
display: flex;
align-items: baseline;
gap: 7px;
}
.identity-basis__k {
font-size: 11px;
font-weight: 600;
color: #64748b;
}
.identity-basis__v {
font-size: 13.5px;
font-weight: 600;
color: #1e293b;
}
.identity-basis__hint {
font-size: 11.5px;
color: #64748b;
line-height: 1.45;
}
.identity-basis__warn {
font-size: 11.5px;
font-weight: 600;
color: #c2410c;
line-height: 1.45;
}
/* ═══════════════════════════════════════════════════════════════
PAN ↔ Aadhaar premium status card
─── Replaces the legacy table-row treatment. Dark gradient with
offset glow circles, large gradient icon, and a state-driven
accent (green / red / blue / amber).
═══════════════════════════════════════════════════════════════ */
.pal-card {
margin-top: 16px;
width: 100%;
min-height: 88px;
padding: 14px 18px 30px;
border-radius: 18px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #111827, #0f172a);
border: 1px solid rgb(255 255 255 / 8%);
box-shadow:
0 12px 32px rgb(0 0 0 / 35%),
inset 0 1px 1px rgb(255 255 255 / 5%);
font-family: Inter, 'Segoe UI', Roboto, sans-serif;
/* Decorative glows — colors swap with the state variant */
&::before {
content: '';
position: absolute;
width: 180px;
height: 180px;
background: #2563eb;
border-radius: 50%;
top: -110px;
right: -70px;
opacity: 0.18;
pointer-events: none;
}
&::after {
content: '';
position: absolute;
width: 120px;
height: 120px;
background: #22c55e;
border-radius: 50%;
bottom: -70px;
left: -50px;
opacity: 0.12;
pointer-events: none;
}
&__left {
display: flex;
align-items: center;
gap: 14px;
z-index: 2;
}
&__icon {
width: 54px;
height: 54px;
border-radius: 16px;
background: linear-gradient(145deg, #2563eb, #06b6d4);
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 22px;
position: relative;
box-shadow: 0 10px 20px rgb(37 99 235 / 35%);
flex-shrink: 0;
mat-icon {
font-size: 26px;
width: 26px;
height: 26px;
}
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: 16px;
border: 1px solid rgb(255 255 255 / 20%);
}
}
&__txt {
display: flex;
flex-direction: column;
gap: 4px;
}
&__small {
font-size: 10.5px;
color: #94a3b8;
letter-spacing: 0.1em;
text-transform: uppercase;
font-weight: 700;
}
&__title {
color: #fff;
font-size: 17px;
font-weight: 700;
line-height: 1.1;
}
&__status {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 4px;
padding: 4px 11px;
border-radius: 50px;
background: rgb(34 197 94 / 12%);
border: 1px solid rgb(74 222 128 / 25%);
color: #4ade80;
font-size: 11.5px;
font-weight: 600;
backdrop-filter: blur(5px);
width: fit-content;
mat-icon {
font-size: 13px;
width: 13px;
height: 13px;
}
}
&__badge {
z-index: 2;
width: 42px;
height: 42px;
border-radius: 50%;
background: linear-gradient(135deg, #22c55e, #16a34a);
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
box-shadow: 0 0 18px rgb(34 197 94 / 45%);
flex-shrink: 0;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
&__hint {
position: absolute;
bottom: 8px;
right: 16px;
font-size: 10px;
color: rgb(255 255 255 / 35%);
font-style: italic;
letter-spacing: 0.02em;
z-index: 1;
}
/* ─── State variants ─── */
&--ok {
&::before {
background: #2563eb;
}
&::after {
background: #22c55e;
}
}
&--fail {
border-color: rgb(239 68 68 / 22%);
&::before {
background: #ef4444;
opacity: 0.22;
}
&::after {
background: #f87171;
opacity: 0.16;
}
.pal-card__status {
background: rgb(239 68 68 / 14%);
border-color: rgb(248 113 113 / 32%);
color: #fca5a5;
}
.pal-card__badge {
background: linear-gradient(135deg, #ef4444, #b91c1c);
box-shadow: 0 0 18px rgb(239 68 68 / 45%);
}
.pal-card__icon {
background: linear-gradient(145deg, #ef4444, #f97316);
box-shadow: 0 10px 20px rgb(239 68 68 / 35%);
}
}
&--info {
border-color: rgb(59 130 246 / 22%);
&::before {
background: #3b82f6;
opacity: 0.22;
}
&::after {
background: #60a5fa;
opacity: 0.14;
}
.pal-card__status {
background: rgb(59 130 246 / 14%);
border-color: rgb(96 165 250 / 32%);
color: #93c5fd;
}
.pal-card__badge {
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
box-shadow: 0 0 18px rgb(59 130 246 / 45%);
}
}
&--pending {
border-color: rgb(245 158 11 / 22%);
&::before {
background: #f59e0b;
opacity: 0.22;
}
&::after {
background: #fbbf24;
opacity: 0.14;
}
.pal-card__status {
background: rgb(245 158 11 / 14%);
border-color: rgb(251 191 36 / 32%);
color: #fbbf24;
}
.pal-card__badge {
background: linear-gradient(135deg, #f59e0b, #b45309);
box-shadow: 0 0 18px rgb(245 158 11 / 45%);
}
.pal-card__icon {
background: linear-gradient(145deg, #f59e0b, #d97706);
box-shadow: 0 10px 20px rgb(245 158 11 / 35%);
}
}
}
@media (width <= 540px) {
.pal-card {
flex-wrap: wrap;
gap: 10px;
padding-bottom: 36px;
&__hint {
position: static;
width: 100%;
text-align: right;
margin-top: 4px;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -68,6 +68,32 @@
</div>
}
<!--
The OVD captured at onboarding has gone stale, or is about to. Sits beside the exception
banner because it is the same kind of thing: something staff must act on before the account
drifts out of compliance.
-->
@if (showKycDocumentAlert) {
<div class="kyc-doc-alert" [class.kyc-doc-alert--critical]="kycDocumentAlertSeverity === 'critical'">
<mat-icon class="kyc-doc-alert__icon">{{
kycDocumentAlertSeverity === 'critical' ? 'error' : 'schedule'
}}</mat-icon>
<div class="kyc-doc-alert__body">
<strong class="kyc-doc-alert__title">{{ kycDocumentAlertTitle }}</strong>
<p class="kyc-doc-alert__text">{{ kycDocumentAlertMessage }}</p>
</div>
<button
mat-flat-button
color="primary"
type="button"
class="kyc-doc-alert__action"
(click)="toggleEditMode()"
>
Update Document
</button>
</div>
}
<!-- Verification Table -->
<div class="kv-table">
<div class="kv-table__header">
@@ -87,12 +113,21 @@
<span class="kv-table__col kv-table__col--status">
@if (kycData.panVerified) {
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (panDeclarationTaken) {
<!-- Deliberately not "Verified": the check never passed, paperwork covers it. -->
<span class="kv-status kv-status--declared"><mat-icon>assignment_turned_in</mat-icon> Declaration</span>
} @else {
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.panVerifiedOn ? formatDate(kycData.panVerifiedOn) : '—'
kycData.panVerified
? kycData.panVerifiedOn
? formatDate(kycData.panVerifiedOn)
: '—'
: panDeclarationTaken && kycData.panDeclarationTakenOn
? formatDate(kycData.panDeclarationTakenOn)
: '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
<button
@@ -108,9 +143,80 @@
Re-Verify
}
</button>
<!--
A PAN that has never passed is not always fixable by another attempt. Offer the
declaration here too, so an existing client sitting in the exception list can be
cleared without the officer having to run a verification they know will fail.
-->
@if (!kycData.panVerified && !panDeclarationTaken && kycData.panNumber) {
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="!kycId || saving"
(click)="takePanDeclaration()"
>
Declaration
</button>
}
</span>
</div>
<!--
PAN is unverified but a declaration is on file. Shown in full because it is the only
reason PAN carries no KYC exception — a reviewer has to see what was declared.
-->
@if (!kycData.panVerified && panDeclarationTaken) {
<div class="kv-decl">
<div class="kv-decl__head">
<mat-icon>assignment_turned_in</mat-icon>
<span class="kv-decl__title">PAN Declaration</span>
@if (!saving) {
<span class="kv-decl__actions">
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takePanDeclaration()">
Edit
</button>
<button
mat-stroked-button
type="button"
class="kv-action-btn danger"
(click)="removePanDeclaration()"
>
Remove
</button>
</span>
}
</div>
<div class="kv-decl__rows">
<div class="kv-decl__row">
<span class="k">Does not match</span>
<span class="v strong">{{ panDeclarationMismatchLabel }}</span>
</div>
@if (kycData.panDeclarationNameOnPan) {
<div class="kv-decl__row">
<span class="k">Name on PAN</span>
<span class="v">{{ kycData.panDeclarationNameOnPan }}</span>
</div>
}
@if (kycData.panDeclarationDobOnPan) {
<div class="kv-decl__row">
<span class="k">DOB on PAN</span>
<span class="v">{{ formatDate(kycData.panDeclarationDobOnPan) }}</span>
</div>
}
@if (kycData.panDeclarationRemarks) {
<div class="kv-decl__row">
<span class="k">Declaration file</span>
<span class="v">{{ kycData.panDeclarationRemarks }}</span>
</div>
}
</div>
<div class="kv-decl__note">
PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file.
</div>
</div>
}
<!-- Aadhaar Row -->
<div class="kv-table__row">
<span class="kv-table__col kv-table__col--doc">
@@ -120,12 +226,21 @@
<span class="kv-table__col kv-table__col--status">
@if (kycData.aadhaarVerified) {
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (aadhaarReceiptTaken) {
<!-- Deliberately not "Verified": no Aadhaar exists yet, the slip covers it. -->
<span class="kv-status kv-status--receipt"><mat-icon>receipt_long</mat-icon> Receipt</span>
} @else {
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.aadhaarVerifiedOn ? formatDate(kycData.aadhaarVerifiedOn) : '—'
kycData.aadhaarVerified
? kycData.aadhaarVerifiedOn
? formatDate(kycData.aadhaarVerifiedOn)
: '—'
: aadhaarReceiptTaken && kycData.aadhaarReceiptTakenOn
? formatDate(kycData.aadhaarReceiptTakenOn)
: '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
@@ -139,9 +254,142 @@
Re-Verify
</button>
}
<!--
No Aadhaar number means the OTP check has nothing to run against. Offer the enrolment
slip here so an existing client sitting in the exception list can be cleared.
-->
@if (!kycData.aadhaarVerified && !aadhaarReceiptTaken) {
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="!kycId || saving"
(click)="takeAadhaarReceipt()"
>
Receipt
</button>
}
</span>
</div>
<!--
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
only reason Aadhaar carries no KYC exception.
-->
@if (!kycData.aadhaarVerified && aadhaarReceiptTaken) {
<div class="kv-rcpt">
<div class="kv-rcpt__head">
<mat-icon>receipt_long</mat-icon>
<span class="kv-rcpt__title">Aadhaar Enrolment Receipt</span>
@if (!saving) {
<span class="kv-rcpt__actions">
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takeAadhaarReceipt()">
Edit
</button>
<button
mat-stroked-button
type="button"
class="kv-action-btn danger"
(click)="removeAadhaarReceipt()"
>
Remove
</button>
</span>
}
</div>
<div class="kv-rcpt__rows">
<div class="kv-rcpt__row">
<span class="k">Enrolment ID</span>
<span class="v mono">{{ kycData.aadhaarEnrolmentId || '—' }}</span>
</div>
@if (kycData.aadhaarEnrolmentDate) {
<div class="kv-rcpt__row">
<span class="k">Enrolment date</span>
<span class="v">{{ formatDate(kycData.aadhaarEnrolmentDate) }}</span>
</div>
}
@if (kycData.aadhaarReceiptRemarks) {
<div class="kv-rcpt__row">
<span class="k">Remarks</span>
<span class="v">{{ kycData.aadhaarReceiptRemarks }}</span>
</div>
}
</div>
<div class="kv-rcpt__note">
Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file.
</div>
</div>
}
<!--
The OVD collected at onboarding. It has never appeared here, so the dates driving the
renewal alert were invisible — an officer told to update the document could not see which
one, nor when it was issued.
-->
@if (kycData.kycDocumentName) {
<div class="kv-table__row">
<span class="kv-table__col kv-table__col--doc">
<mat-icon class="kv-doc-icon">description</mat-icon> {{ kycData.kycDocumentName }}
</span>
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.kycDocumentNumber || '—' }}</span>
<span class="kv-table__col kv-table__col--status">
@switch (kycDocumentRenewal.state) {
@case ('EXPIRED') {
<span class="kv-status kv-status--fail"><mat-icon>error</mat-icon> Expired</span>
}
@case ('EXPIRING_SOON') {
<span class="kv-status kv-status--declared"><mat-icon>schedule</mat-icon> Expires Soon</span>
}
@case ('DUE') {
<span class="kv-status kv-status--declared"><mat-icon>update</mat-icon> Update Due</span>
}
@case ('OK') {
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Current</span>
}
@default {
<span class="kv-status kv-status--pending"><mat-icon>help_outline</mat-icon> No dates</span>
}
}
</span>
<!--
Issued-on, not verified-on: this document is sighted, never API-verified.
Every document shows an expiry, printed or not: one with no expiry on the card is
still only good for five years from issue, so the derived date is shown rather than a
blank. It is tagged so a calculated date is never mistaken for one on the document.
-->
<span class="kv-table__col kv-table__col--date">
@if (kycData.kycDocIssueDate || kycData.kycDocExpiryDate) {
<span class="kv-doc-dates">
@if (kycData.kycDocIssueDate) {
<span>Issued {{ formatDate(kycData.kycDocIssueDate) }}</span>
}
@if (kycDocumentExpiresOn) {
<span
class="kv-doc-dates__expiry"
[class.kv-doc-dates__expiry--derived]="kycDocumentExpiryIsDerived"
>
Expires {{ kycDocumentExpiresOn }}
@if (kycDocumentExpiryIsDerived) {
<span
class="kv-doc-dates__tag"
matTooltip="No expiry printed on this document — calculated as 5 years from the issue date"
>5-yr</span
>
}
</span>
}
</span>
} @else {
—
}
</span>
<span class="kv-table__col kv-table__col--action">
<button mat-stroked-button class="kv-action-btn" type="button" (click)="toggleEditMode()">Update</button>
</span>
</div>
}
<!-- GSTIN Row (Non-Individual only) -->
@if (isNonIndividual) {
<div class="kv-table__row">
@@ -360,39 +608,72 @@
<!-- KYC Meta Grid -->
<div class="kv-meta-grid">
<div class="kv-meta-item">
<span class="kv-meta-label">KYC Type</span>
<span class="kv-meta-value">{{
kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—'
}}</span>
<mat-icon class="kv-meta-icon icon-blue">assignment_ind</mat-icon>
<div class="kv-meta-info">
<span class="kv-meta-label">KYC Type</span>
<span class="kv-meta-value">
{{ kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—' }}
</span>
</div>
</div>
<div class="kv-meta-item">
<span class="kv-meta-label">Risk Category</span>
<span class="kv-meta-value">{{
kycData.riskCategory === 1
? 'Low'
: kycData.riskCategory === 2
? 'Medium'
: kycData.riskCategory === 3
? 'High'
: '—'
}}</span>
<mat-icon
class="kv-meta-icon"
[ngClass]="{
'icon-green': kycData.riskCategory === 1,
'icon-orange': kycData.riskCategory === 2,
'icon-red': kycData.riskCategory === 3
}"
>shield</mat-icon
>
<div class="kv-meta-info">
<span class="kv-meta-label">Risk Category</span>
<span class="kv-meta-value">
{{
kycData.riskCategory === 1
? 'Low'
: kycData.riskCategory === 2
? 'Medium'
: kycData.riskCategory === 3
? 'High'
: '—'
}}
</span>
</div>
</div>
<div class="kv-meta-item">
<span class="kv-meta-label">Initiated On</span>
<span class="kv-meta-value">{{ formatDate(kycData.initiatedOn) }}</span>
<mat-icon class="kv-meta-icon icon-grey">calendar_today</mat-icon>
<div class="kv-meta-info">
<span class="kv-meta-label">Initiated On</span>
<span class="kv-meta-value">{{ formatDate(kycData.initiatedOn) }}</span>
</div>
</div>
<div class="kv-meta-item">
<span class="kv-meta-label">Last Verified</span>
<span class="kv-meta-value">{{ formatDate(kycData.verifiedOn) }}</span>
<mat-icon class="kv-meta-icon icon-green">check_circle</mat-icon>
<div class="kv-meta-info">
<span class="kv-meta-label">Last Verified</span>
<span class="kv-meta-value">{{ formatDate(kycData.verifiedOn) }}</span>
</div>
</div>
<div class="kv-meta-item">
<span class="kv-meta-label">Expiry Date</span>
<span class="kv-meta-value">{{ formatDate(kycData.expiryDate) }}</span>
<mat-icon class="kv-meta-icon icon-red">history_toggle_off</mat-icon>
<div class="kv-meta-info">
<span class="kv-meta-label">Expiry Date</span>
<span class="kv-meta-value">{{ formatDate(kycData.expiryDate) }}</span>
</div>
</div>
@if (kycData.remarks) {
<div class="kv-meta-item kv-meta-item--full">
<span class="kv-meta-label">Remarks</span>
<span class="kv-meta-value">{{ kycData.remarks }}</span>
<mat-icon class="kv-meta-icon icon-grey">notes</mat-icon>
<div class="kv-meta-info">
<span class="kv-meta-label">Remarks</span>
<span class="kv-meta-value">{{ kycData.remarks }}</span>
</div>
</div>
}
</div>
@@ -567,6 +848,22 @@
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
<span>{{ verificationError.pan }}</span>
</div>
<!--
The check can fail for reasons no retry will fix — a PAN card that spells the
name differently, or a vendor that is simply down. The declaration is the way
out, so it is offered here rather than leaving the officer at a dead end.
-->
<div class="kyc-decl-offer">
<span>Customer's PAN card genuinely carries different details, or PAN cannot be checked?</span>
<button
mat-stroked-button
type="button"
[disabled]="!kycId || saving"
(click)="takePanDeclaration()"
>
{{ panDeclarationTaken ? 'Edit Declaration' : 'Take Declaration' }}
</button>
</div>
}
@if (verificationStatus.pan === 'VERIFIED' && verificationDetails.pan?.name) {
<span class="verification-detail-text">{{ verificationDetails.pan.name }}</span>
@@ -678,6 +975,22 @@
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
<span>{{ verificationError.aadhaar }}</span>
</div>
<!--
A failed OTP is not always something a retry can fix — a customer who has only
applied for Aadhaar has no number to verify at all. The enrolment slip is the way
out, so it is offered here rather than leaving the officer at a dead end.
-->
<div class="kyc-decl-offer">
<span>Customer has applied for Aadhaar but not received it yet?</span>
<button
mat-stroked-button
type="button"
[disabled]="!kycId || saving"
(click)="takeAadhaarReceipt()"
>
{{ aadhaarReceiptTaken ? 'Edit Receipt' : 'Record Receipt' }}
</button>
</div>
}
</div>
@@ -559,6 +559,19 @@
background: #eff6ff;
color: #2563eb;
}
// Unverified, covered by a declaration — amber, never green, so it cannot read as a pass.
&--declared {
background: #fffbeb;
color: #b45309;
}
// Covered by an enrolment slip. Blue rather than amber: nothing failed, the customer simply
// does not hold the document yet.
&--receipt {
background: #eff6ff;
color: #2563eb;
}
}
.kv-action-btn {
@@ -570,6 +583,265 @@
font-weight: 600 !important;
color: #1074b9 !important;
border-color: #1074b9 !important;
&.danger {
color: #b91c1c !important;
border-color: #fca5a5 !important;
}
}
/* PAN declaration detail, shown under the PAN row when the check did not pass */
.kv-decl {
margin: 4px 0 8px;
padding: 12px 14px;
border: 1px solid #fcd34d;
border-radius: 8px;
background: #fffbeb;
}
.kv-decl__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
mat-icon {
color: #b45309;
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
}
}
.kv-decl__title {
font-size: 13px;
font-weight: 600;
color: #92400e;
}
.kv-decl__actions {
margin-left: auto;
display: flex;
gap: 8px;
}
.kv-decl__rows {
display: flex;
flex-direction: column;
gap: 4px;
}
.kv-decl__row {
display: grid;
grid-template-columns: 140px 1fr;
gap: 10px;
font-size: 12.5px;
.k {
color: #92400e;
font-weight: 500;
}
.v {
color: #451a03;
word-break: break-word;
&.strong {
font-weight: 600;
}
}
}
.kv-decl__note {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid #fde68a;
font-size: 11.5px;
line-height: 1.5;
color: #b45309;
}
/* KYC document renewal alert — amber for a stale document, red once it has actually expired */
.kyc-doc-alert {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
margin-bottom: 20px;
border: 1px solid #f59e0b;
border-radius: 8px;
background: #fef3c7;
&--critical {
border-color: #ef4444;
background: #fee2e2;
}
}
.kyc-doc-alert__icon {
flex: 0 0 auto;
margin-top: 2px;
color: #d97706;
.kyc-doc-alert--critical & {
color: #dc2626;
}
}
.kyc-doc-alert__body {
flex: 1 1 auto;
min-width: 0;
}
.kyc-doc-alert__title {
font-size: 14px;
color: #92400e;
.kyc-doc-alert--critical & {
color: #991b1b;
}
}
.kyc-doc-alert__text {
margin: 4px 0 0;
font-size: 13px;
line-height: 1.5;
color: #b45309;
.kyc-doc-alert--critical & {
color: #b91c1c;
}
}
.kyc-doc-alert__action {
flex: 0 0 auto;
align-self: center;
}
/* Issue / expiry pair in the document row */
.kv-doc-dates {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
}
.kv-doc-dates__expiry {
display: inline-flex;
align-items: center;
gap: 6px;
color: #64748b;
/* Calculated from the issue date rather than printed on the document. */
&--derived {
color: #94a3b8;
}
}
.kv-doc-dates__tag {
padding: 0 5px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
font-size: 9.5px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #94a3b8;
cursor: default;
}
/* Aadhaar enrolment receipt detail, shown under the Aadhaar row when the check did not pass */
.kv-rcpt {
margin: 4px 0 8px;
padding: 12px 14px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
}
.kv-rcpt__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
mat-icon {
color: #2563eb;
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
}
}
.kv-rcpt__title {
font-size: 13px;
font-weight: 600;
color: #1e3a8a;
}
.kv-rcpt__actions {
margin-left: auto;
display: flex;
gap: 8px;
}
.kv-rcpt__rows {
display: flex;
flex-direction: column;
gap: 4px;
}
.kv-rcpt__row {
display: grid;
grid-template-columns: 140px 1fr;
gap: 10px;
font-size: 12.5px;
.k {
color: #1e40af;
font-weight: 500;
}
.v {
color: #0f172a;
word-break: break-word;
&.mono {
font-family: monospace;
letter-spacing: 0.02em;
}
}
}
.kv-rcpt__note {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid #bfdbfe;
font-size: 11.5px;
line-height: 1.5;
color: #2563eb;
}
/* Offer shown beneath a failed PAN verification in edit mode */
.kyc-decl-offer {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 8px;
padding: 10px 12px;
border: 1px solid #fde68a;
border-radius: 6px;
background: #fffbeb;
font-size: 12.5px;
color: #92400e;
button {
margin-left: auto;
color: #b45309 !important;
border-color: #fcd34d !important;
}
}
/* OTP Bar (below table) */
@@ -622,27 +894,76 @@
.kv-meta-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
margin-top: 16px;
border-radius: 12px;
padding: 16px 20px;
gap: 20px;
margin-top: 20px;
box-shadow:
0 1px 3px rgb(0 0 0 / 2%),
0 4px 6px -1px rgb(0 0 0 / 5%);
}
.kv-meta-item {
display: flex;
flex-direction: column;
gap: 3px;
padding: 12px 14px;
border-right: 1px solid #f1f5f9;
border-bottom: 1px solid #f1f5f9;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: 8px;
transition: all 0.2s ease;
&:nth-child(5n) {
border-right: none;
&:hover {
background: #f8fafc;
}
&--full {
grid-column: 1 / -1;
border-right: none;
border-top: 1px solid #f1f5f9;
padding-top: 16px;
margin-top: 4px;
border-radius: 0;
align-items: flex-start;
&:hover {
background: transparent;
}
}
}
.kv-meta-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.kv-meta-icon {
font-size: 24px !important;
width: 24px !important;
height: 24px !important;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.icon-blue {
color: #3b82f6;
}
&.icon-green {
color: #10b981;
}
&.icon-orange {
color: #f59e0b;
}
&.icon-red {
color: #ef4444;
}
&.icon-grey {
color: #64748b;
}
}
@@ -656,7 +977,7 @@
.kv-meta-value {
font-size: 13px;
font-weight: 500;
font-weight: 600;
color: #1e293b;
}
@@ -677,14 +998,7 @@
.kv-meta-grid {
grid-template-columns: repeat(3, 1fr);
}
.kv-meta-item:nth-child(5n) {
border-right: 1px solid #f1f5f9;
}
.kv-meta-item:nth-child(3n) {
border-right: none;
gap: 16px;
}
}
@@ -10,11 +10,26 @@
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { catchError, of, Subject, takeUntil } from 'rxjs';
/** Custom Services */
import { ClientsService } from '../../clients.service';
import { deriveKycExceptionFields } from '../../kyc-exception.util';
import {
deriveKycDocumentRenewal,
kycDocumentNeedsAttention,
KycDocumentRenewalStatus,
KYC_DOCUMENT_RENEWAL_YEARS
} from '../../kyc-document-renewal.util';
import {
PanDeclarationDialogComponent,
PanDeclarationResult
} from '../../shared/pan-declaration-dialog/pan-declaration-dialog.component';
import {
AadhaarReceiptDialogComponent,
AadhaarReceiptResult
} from '../../shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component';
import { Dates } from 'app/core/utils/dates';
import { jsPDF } from 'jspdf';
import { MatIcon } from '@angular/material/icon';
@@ -47,6 +62,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
private clientsService = inject(ClientsService);
private formBuilder = inject(UntypedFormBuilder);
private dateUtils = inject(Dates);
private dialog = inject(MatDialog);
private destroy$ = new Subject<void>();
@@ -200,6 +216,127 @@ export class KycTabComponent implements OnInit, OnDestroy {
}
}
/** True when the stored record carries a signed declaration standing in for the PAN check. */
get panDeclarationTaken(): boolean {
return this.kycData?.panDeclarationTaken === true;
}
/** "Name", "Date of Birth", "Name and Date of Birth", or the not-checked-at-all case. */
get panDeclarationMismatchLabel(): string {
const fields: string = this.kycData?.panDeclarationMismatchFields;
if (!fields || fields === 'NONE') {
return 'PAN could not be verified';
}
return fields
.split(',')
.map((f) => (f === 'NAME' ? 'Name' : 'Date of Birth'))
.join(' and ');
}
/** True when the stored record carries a UIDAI enrolment slip standing in for the OTP check. */
get aadhaarReceiptTaken(): boolean {
return this.kycData?.aadhaarReceiptTaken === true;
}
// ── KYC document renewal ──────────────────────────────────────────────────
/**
* Whether the OVD captured at onboarding is still current — see `deriveKycDocumentRenewal()`.
*
* The issue and expiry dates have always been captured on the KYC step but never read back, so
* a client onboarded on a ten-year-old licence looked identical to one onboarded yesterday.
*/
get kycDocumentRenewal(): KycDocumentRenewalStatus {
return deriveKycDocumentRenewal({
issueDate: this.parseApiDate(this.kycData?.kycDocIssueDate),
expiryDate: this.parseApiDate(this.kycData?.kycDocExpiryDate),
today: new Date()
});
}
/** Only raise the banner for a document that actually exists and actually needs action. */
get showKycDocumentAlert(): boolean {
return !!this.kycData?.kycDocumentName && kycDocumentNeedsAttention(this.kycDocumentRenewal.state);
}
/** Heading for the alert — the three states call for visibly different things. */
get kycDocumentAlertTitle(): string {
switch (this.kycDocumentRenewal.state) {
case 'EXPIRED':
return 'KYC Document Expired';
case 'EXPIRING_SOON':
return 'KYC Document Expires Soon';
default:
return 'KYC Document Due for Update';
}
}
/**
* The sentence staff act on. Each case names the date that triggered it, because "update the
* document" without saying why leaves the officer checking the form to work out which clock ran
* out.
*/
get kycDocumentAlertMessage(): string {
const status = this.kycDocumentRenewal;
const docName = this.kycData?.kycDocumentName || 'The KYC document';
// `formatDate()` only understands Fineract's [y, m, d] arrays; these are real Date objects.
const dueOn = status.dueOn ? this.dateUtils.formatDate(status.dueOn, 'dd MMMM yyyy') : '';
const days = status.daysRemaining ?? 0;
switch (status.state) {
case 'EXPIRED':
return `${docName} expired on ${dueOn} and is no longer a valid officially valid document. Collect a renewed document from the customer and update the KYC record.`;
case 'EXPIRING_SOON':
return days === 0
? `${docName} expires today (${dueOn}). Ask the customer for the renewed document before it lapses.`
: `${docName} expires on ${dueOn} — ${days} day${days === 1 ? '' : 's'} from now. Ask the customer for the renewed document before it lapses.`;
default:
return `${docName} was issued more than ${KYC_DOCUMENT_RENEWAL_YEARS} years ago and fell due for update on ${dueOn}. Re-collect the document from the customer and update the KYC record.`;
}
}
/** Drives the banner's colour — an expired document is a harder failure than a stale one. */
get kycDocumentAlertSeverity(): 'critical' | 'warning' {
return this.kycDocumentRenewal.state === 'EXPIRED' ? 'critical' : 'warning';
}
/**
* The date the document stops counting as current, whether or not one is printed on it.
*
* A Voter ID or NREGA card carries no expiry, but it is not valid forever for KYC purposes —
* the five-year rule is its expiry. Showing the derived date rather than a blank means every
* document answers "valid until when?", and the officer sees the same date the alert will fire on
* instead of having to add five years in their head.
*/
get kycDocumentExpiresOn(): string {
const dueOn = this.kycDocumentRenewal.dueOn;
return dueOn ? this.dateUtils.formatDate(dueOn, 'dd MMMM yyyy') : '';
}
/**
* True when the date above was calculated from the issue date rather than read off the document.
* Flagged in the UI so a derived date is never mistaken for one printed on the card.
*/
get kycDocumentExpiryIsDerived(): boolean {
return this.kycDocumentRenewal.reason === 'AGE';
}
/** Compact chip label for the document row in the verification table. */
get kycDocumentStatusLabel(): string {
switch (this.kycDocumentRenewal.state) {
case 'EXPIRED':
return 'Expired';
case 'EXPIRING_SOON':
return 'Expires Soon';
case 'DUE':
return 'Update Due';
case 'OK':
return 'Current';
default:
return '—';
}
}
/**
* Exception columns derived from verification status — see `deriveKycExceptionFields()`.
*
@@ -213,7 +350,11 @@ export class KycTabComponent implements OnInit, OnDestroy {
return deriveKycExceptionFields({
panVerified,
// A signed declaration closes the PAN item by paperwork when the API never can.
panDeclarationTaken: this.panDeclarationTaken,
aadhaarVerified,
// An enrolment slip closes the Aadhaar item when there is no number to verify yet.
aadhaarReceiptTaken: this.aadhaarReceiptTaken,
aadhaarApplicable: !this.isNonIndividual,
hadException: this.kycData?.isException === true,
previousReason: this.kycData?.exceptionReason || null,
@@ -326,7 +467,12 @@ export class KycTabComponent implements OnInit, OnDestroy {
if (response?.changes?.panVerified) {
this.verificationStatus.pan = 'VERIFIED';
this.verificationDetails.pan = response.changes;
this.loadKycData();
// A PAN that now verifies makes any earlier declaration moot.
if (this.panDeclarationTaken) {
this.writePanDeclaration(null);
} else {
this.loadKycData();
}
} else {
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan = 'PAN verification failed.';
@@ -345,6 +491,283 @@ export class KycTabComponent implements OnInit, OnDestroy {
}, 'pan');
}
/**
* Records the customer declaration that stands in for a PAN check the client cannot pass.
*
* Offered for any PAN failure, not just a name/DOB mismatch — an officer facing an unreachable
* vendor is just as stuck as one facing a misspelt PAN card, and the same signed paperwork
* resolves both. When the failure was not a comparable mismatch there is no PAN card to compare
* against, so the dialog only asks why the declaration is being taken.
*/
takePanDeclaration(): void {
if (!this.kycId) {
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
return;
}
const failureMessage = this.verificationError.pan || 'PAN has not been verified.';
const mismatch = failureMessage.toLowerCase().includes('does not match');
this.dialog
.open(PanDeclarationDialogComponent, {
width: '720px',
maxWidth: '95vw',
disableClose: true,
data: {
submittedName: this.kycData?.clientDisplayName || this.clientData?.displayName || '',
submittedDob: this.clientDateOfBirth || '',
panNumber: this.kycForm.value.panNumber || this.kycData?.panNumber || '',
failureMessage,
// Only a reported name/DOB mismatch gives the officer something to compare against.
unverifiable: !mismatch,
existing: this.panDeclarationTaken
? {
mismatchFields: this.kycData?.panDeclarationMismatchFields || 'NONE',
nameOnPan: this.kycData?.panDeclarationNameOnPan || null,
dobOnPan: this.parseApiDate(this.kycData?.panDeclarationDobOnPan),
// The scan itself lives with the client's documents; the record keeps its name.
file: null,
fileName: this.kycData?.panDeclarationRemarks || ''
}
: null
}
})
.afterClosed()
.subscribe((result: PanDeclarationResult | undefined) => {
if (!result) {
return;
}
// The scan has to be filed against the client before the record says a declaration exists —
// otherwise the PAN exception is waived by a document nobody can produce.
if (result.file) {
this.uploadPanDeclarationFile(result.file, () => this.writePanDeclaration(result));
} else {
this.writePanDeclaration(result);
}
});
}
/** Files the signed declaration under the client's documents, then continues with `onDone`. */
private uploadPanDeclarationFile(file: File, onDone: () => void): void {
this.saving = true;
this.errorMessage = null;
const formData = new FormData();
formData.append('file', file);
formData.append('name', 'PAN Declaration');
formData.append(
'description',
`Signed PAN declaration for ${this.kycForm.value.panNumber || this.kycData?.panNumber || 'PAN'}`
);
this.clientsService
.uploadClientDocument(this.clientId, formData)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.saving = false;
onDone();
},
error: (err: any) => {
this.saving = false;
this.errorMessage =
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
err?.message ||
'Could not upload the signed declaration. Please try again.';
}
});
}
/** Officer-initiated removal — the PAN exception is raised again on this same save. */
removePanDeclaration(): void {
if (this.panDeclarationTaken) {
this.writePanDeclaration(null);
}
}
/**
* Persists (or clears) the declaration on the KYC record.
*
* `buildExceptionFields()` reads the declaration off `kycData`, so that has to be updated before
* the payload is built — otherwise the save would carry the declaration and the PAN exception at
* the same time, which is exactly the contradiction the declaration exists to prevent.
*/
private writePanDeclaration(declaration: PanDeclarationResult | null): void {
this.saving = true;
this.errorMessage = null;
const previous = {
panDeclarationTaken: this.kycData?.panDeclarationTaken,
panDeclarationMismatchFields: this.kycData?.panDeclarationMismatchFields,
panDeclarationNameOnPan: this.kycData?.panDeclarationNameOnPan,
panDeclarationDobOnPan: this.kycData?.panDeclarationDobOnPan,
panDeclarationRemarks: this.kycData?.panDeclarationRemarks,
panDeclarationTakenOn: this.kycData?.panDeclarationTakenOn
};
const dateFormat = 'dd MMMM yyyy';
const declarationFields = {
panDeclarationTaken: !!declaration,
panDeclarationMismatchFields: declaration?.mismatchFields || null,
panDeclarationNameOnPan: declaration?.nameOnPan || null,
panDeclarationDobOnPan: declaration?.dobOnPan
? this.dateUtils.formatDate(declaration.dobOnPan, dateFormat)
: null,
// Names the uploaded declaration filed under the client's documents.
panDeclarationRemarks: declaration?.fileName || null,
panDeclarationTakenOn: declaration ? this.dateUtils.formatDate(new Date(), dateFormat) : null
};
this.kycData = { ...(this.kycData || {}), ...declarationFields };
const payload: any = {
locale: 'en',
dateFormat,
riskCategory: this.kycForm.value.riskCategory || 1,
...declarationFields,
...this.buildExceptionFields()
};
this.clientsService
.updateClientKyc(this.clientId, this.kycId, payload)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.saving = false;
this.loadKycData();
},
error: (err: any) => {
this.saving = false;
// Put the record back so the tab never shows a declaration the server did not accept.
this.kycData = { ...(this.kycData || {}), ...previous };
this.errorMessage =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to save the PAN declaration.';
}
});
}
/**
* Records the UIDAI enrolment receipt accepted in place of a verified Aadhaar.
*
* A customer who has applied for Aadhaar but has not been issued one has no number to put through
* the OTP flow, so the check can never pass. The slip closes the item instead.
*/
takeAadhaarReceipt(): void {
if (!this.kycId) {
this.verificationError.aadhaar = 'KYC record not found. Save KYC details first.';
return;
}
this.dialog
.open(AadhaarReceiptDialogComponent, {
width: '560px',
maxWidth: '95vw',
disableClose: true,
data: {
clientName: this.kycData?.clientDisplayName || this.clientData?.displayName || '',
reason:
this.verificationError.aadhaar ||
'Aadhaar has not been verified. Record the enrolment slip if the customer has applied but not yet received their Aadhaar.',
existing: this.aadhaarReceiptTaken
? {
enrolmentId: this.kycData?.aadhaarEnrolmentId || '',
enrolmentDate: this.parseApiDate(this.kycData?.aadhaarEnrolmentDate),
remarks: this.kycData?.aadhaarReceiptRemarks || ''
}
: null
}
})
.afterClosed()
.subscribe((result: AadhaarReceiptResult | undefined) => {
if (result) {
this.writeAadhaarReceipt(result);
}
});
}
/** Officer-initiated removal — the Aadhaar exception is raised again on this same save. */
removeAadhaarReceipt(): void {
if (this.aadhaarReceiptTaken) {
this.writeAadhaarReceipt(null);
}
}
/**
* Persists (or clears) the enrolment receipt on the KYC record.
*
* `buildExceptionFields()` reads the receipt off `kycData`, so that has to be updated before the
* payload is built — otherwise the save would carry the receipt and the Aadhaar exception at the
* same time, the contradiction the receipt exists to prevent.
*/
private writeAadhaarReceipt(receipt: AadhaarReceiptResult | null): void {
this.saving = true;
this.errorMessage = null;
const previous = {
aadhaarReceiptTaken: this.kycData?.aadhaarReceiptTaken,
aadhaarEnrolmentId: this.kycData?.aadhaarEnrolmentId,
aadhaarEnrolmentDate: this.kycData?.aadhaarEnrolmentDate,
aadhaarReceiptRemarks: this.kycData?.aadhaarReceiptRemarks,
aadhaarReceiptTakenOn: this.kycData?.aadhaarReceiptTakenOn
};
const dateFormat = 'dd MMMM yyyy';
const receiptFields = {
aadhaarReceiptTaken: !!receipt,
aadhaarEnrolmentId: receipt?.enrolmentId || null,
aadhaarEnrolmentDate: receipt?.enrolmentDate
? this.dateUtils.formatDate(receipt.enrolmentDate, dateFormat)
: null,
aadhaarReceiptRemarks: receipt?.remarks || null,
aadhaarReceiptTakenOn: receipt ? this.dateUtils.formatDate(new Date(), dateFormat) : null
};
this.kycData = { ...(this.kycData || {}), ...receiptFields };
const payload: any = {
locale: 'en',
dateFormat,
riskCategory: this.kycForm.value.riskCategory || 1,
...receiptFields,
...this.buildExceptionFields()
};
this.clientsService
.updateClientKyc(this.clientId, this.kycId, payload)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.saving = false;
this.verificationError.aadhaar = null;
this.loadKycData();
},
error: (err: any) => {
this.saving = false;
// Put the record back so the tab never shows a receipt the server did not accept.
this.kycData = { ...(this.kycData || {}), ...previous };
this.errorMessage =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to save the Aadhaar enrolment receipt.';
}
});
}
/** Fineract serialises dates as `[yyyy, M, d]`; the same field can also arrive as a string. */
private parseApiDate(raw: any): Date | null {
if (!raw) {
return null;
}
if (Array.isArray(raw) && raw.length >= 3) {
return new Date(raw[0], raw[1] - 1, raw[2]);
}
const parsed = new Date(raw);
return isNaN(parsed.getTime()) ? null : parsed;
}
/** Show Aadhaar input in summary view for re-verification */
startAadhaarReVerify(): void {
this.showAadhaarReVerify = true;
@@ -416,7 +839,13 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.verificationStatus.aadhaar = 'VERIFIED';
this.verificationDetails.aadhaar = response.changes;
this.resetOtpState();
this.loadKycData();
// Aadhaar has been issued and verified, so the enrolment slip has served its purpose —
// drop it rather than leave the record claiming the customer is still waiting.
if (this.aadhaarReceiptTaken) {
this.writeAadhaarReceipt(null);
} else {
this.loadKycData();
}
} else {
this.verificationStatus.aadhaar = 'FAILED';
this.verificationError.aadhaar = 'OTP verification failed.';
@@ -619,13 +1048,43 @@ export class KycTabComponent implements OnInit, OnDestroy {
// PAN
y = addLine('PAN Number:', kyc.panNumber || '—', y);
y = addLine('PAN Status:', kyc.panVerified ? 'Verified' : 'Pending', y);
// "Pending" would misdescribe a PAN that was checked, failed, and was settled by declaration —
// nothing is pending on that record, and the certificate has to say what actually happened.
y = addLine(
'PAN Status:',
kyc.panVerified ? 'Verified' : this.panDeclarationTaken ? 'Not Verified — Declaration Taken' : 'Pending',
y
);
if (kyc.panVerifiedOn) y = addLine('PAN Verified On:', this.formatDate(kyc.panVerifiedOn), y);
if (!kyc.panVerified && this.panDeclarationTaken) {
y = addLine('Declared Mismatch:', this.panDeclarationMismatchLabel, y);
if (kyc.panDeclarationNameOnPan) y = addLine('Name on PAN:', kyc.panDeclarationNameOnPan, y);
if (kyc.panDeclarationDobOnPan) {
y = addLine('DOB on PAN:', this.formatDate(kyc.panDeclarationDobOnPan), y);
}
if (kyc.panDeclarationTakenOn) {
y = addLine('Declaration Taken On:', this.formatDate(kyc.panDeclarationTakenOn), y);
}
}
// Aadhaar
y = addLine('Aadhaar Number:', kyc.aadhaarNumberMasked || '—', y);
y = addLine('Aadhaar Status:', kyc.aadhaarVerified ? 'Verified' : 'Pending', y);
// As with PAN: "Pending" would misdescribe a client who holds only an enrolment slip.
y = addLine(
'Aadhaar Status:',
kyc.aadhaarVerified ? 'Verified' : this.aadhaarReceiptTaken ? 'Not Verified — Enrolment Receipt' : 'Pending',
y
);
if (kyc.aadhaarVerifiedOn) y = addLine('Aadhaar Verified On:', this.formatDate(kyc.aadhaarVerifiedOn), y);
if (!kyc.aadhaarVerified && this.aadhaarReceiptTaken) {
if (kyc.aadhaarEnrolmentId) y = addLine('Enrolment ID:', kyc.aadhaarEnrolmentId, y);
if (kyc.aadhaarEnrolmentDate) {
y = addLine('Enrolment Date:', this.formatDate(kyc.aadhaarEnrolmentDate), y);
}
if (kyc.aadhaarReceiptTakenOn) {
y = addLine('Receipt Recorded On:', this.formatDate(kyc.aadhaarReceiptTakenOn), y);
}
}
// PAN-Aadhaar Link
const seedingStatus =
-6
View File
@@ -98,12 +98,6 @@
</td>
</ng-container>
<!-- Client ID Column -->
<ng-container matColumnDef="accountNumber">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Client ID</th>
<td mat-cell *matCellDef="let row" class="cell-mono">{{ row.id }}</td>
</ng-container>
<!-- CIF Number Column -->
<ng-container matColumnDef="cifNumber">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.CIF Number' | translate }}</th>
-1
View File
@@ -88,7 +88,6 @@ export class ClientsComponent implements OnInit {
displayedColumns = [
'displayName',
'accountNumber',
'cifNumber',
'legalForm',
'status',
+9
View File
@@ -809,6 +809,15 @@ export class ClientsService {
return this.http.get<any[]>('/kyc/risk-rules');
}
/**
* Adds a rule to the AML catalog. Amount/window details in the payload also create the
* matching live-transaction rule server side; the response is the persisted rule in the same
* shape as {@link getClientRiskRules}.
*/
createClientRiskRule(rule: any): Observable<any> {
return this.http.post('/kyc/risk-rules', rule);
}
/** Fetch code values by code ID */
getCodeValuesById(codeId: number): Observable<any[]> {
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
@@ -75,6 +75,8 @@
[clientId]="createdClientId"
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
[verificationName]="clientGeneralStep?.verificationName || ''"
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
[readOnly]="isTabReadOnly(1)"
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
></mifosx-client-kyc-step>
@@ -223,6 +223,15 @@ export class CreateClientComponent {
// KYC tab — block when the kyc form has unfilled required fields
// (kycDocumentName / kycDocumentNumber / pattern errors on PAN / Aadhaar etc.)
const kf = this.clientKycStep?.kycForm;
if (kf && !kf.valid) {
const invalid = [];
for (const name in kf.controls) {
if (kf.controls[name].invalid) {
invalid.push({ name, errors: kf.controls[name].errors, value: kf.controls[name].value });
}
}
console.log('--- KYC Form is INVALID. Offending controls:', invalid);
}
return !kf || kf.valid;
}
if (this.legalFormType === 2) {
@@ -824,6 +833,9 @@ export class CreateClientComponent {
* Mirrors identities-tab.createIdentifierWithDocs.
*/
private saveAddressProof(clientId: string) {
if (this.clientIndiaAddressStep?.existingAddressProofIdentifierId) {
return;
}
const payload = this.clientIndiaAddressStep?.addressProofPayload;
if (!payload?.proofId) return;
@@ -839,6 +851,9 @@ export class CreateClientComponent {
next: (res: any) => {
const identifierId = res?.resourceId;
if (!identifierId) return;
if (this.clientIndiaAddressStep) {
this.clientIndiaAddressStep.existingAddressProofIdentifierId = identifierId.toString();
}
for (const entry of payload.files) {
const formData = new FormData();
formData.append('name', `${proofName} — ${entry.slot}`);
@@ -855,6 +870,9 @@ export class CreateClientComponent {
* + upload each selected file as a document attached to that identifier.
*/
private saveKycDocProof(clientId: string) {
if (this.clientKycStep?.existingKycIdentifierId) {
return;
}
const payload = this.clientKycStep?.kycDocPayload;
if (!payload?.proofId || !payload?.files?.length) return;
@@ -882,6 +900,9 @@ export class CreateClientComponent {
next: (res: any) => {
const identifierId = res?.resourceId;
if (!identifierId) return;
if (this.clientKycStep) {
this.clientKycStep.existingKycIdentifierId = identifierId.toString();
}
for (const entry of payload.files) {
const formData = new FormData();
formData.append('name', `${proofName} — ${entry.slot}`);
@@ -906,8 +927,45 @@ export class CreateClientComponent {
return this.clientsService.getKycProofTypes('id').pipe(
map((res: any) => {
const list: any[] = res?.idProofs || [];
const targetName = (proofName || '').trim().toLowerCase();
const match = list.find((p: any) => (p?.name || '').trim().toLowerCase() === targetName);
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const target = cleanStr(proofName || proofId);
// 1. Try exact code/name match
let match = list.find((p: any) => {
const pcode = cleanStr(p.code);
const pname = cleanStr(p.name);
return pcode === target || pname === target;
});
// 2. Try loose contains/includes match
if (!match) {
match = list.find((p: any) => {
const pcode = cleanStr(p.code);
const pname = cleanStr(p.name);
return pcode.includes(target) || pname.includes(target) || target.includes(pcode) || target.includes(pname);
});
}
// 3. Specific fallbacks for common keywords
if (!match && target.includes('voter')) {
match = list.find((p: any) => {
const name = cleanStr(p.name);
return name.includes('voter') || name.includes('epic');
});
}
if (!match && target.includes('aadhaar')) {
match = list.find((p: any) => cleanStr(p.name).includes('aadhaar'));
}
if (!match && target.includes('passport')) {
match = list.find((p: any) => cleanStr(p.name).includes('passport'));
}
if (!match && target.includes('driving')) {
match = list.find((p: any) => cleanStr(p.name).includes('drive') || cleanStr(p.name).includes('licen'));
}
if (!match && target.includes('pan')) {
match = list.find((p: any) => cleanStr(p.name).includes('pan'));
}
return (match?.id ?? null) as number | null;
})
);
@@ -220,6 +220,8 @@
[isNonIndividual]="legalFormType === 2"
[isMinor]="clientGeneralStep?.isMinor || false"
[clientId]="clientId"
[verificationName]="clientGeneralStep?.verificationName || ''"
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
[readOnly]="isTabReadOnly(1)"
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
></mifosx-client-kyc-step>
@@ -342,7 +344,6 @@
[degreeQualificationOptions]="clientGeneralStep?.degreeQualificationOptions || []"
[religionOptions]="clientGeneralStep?.religionOptions || []"
[socialCategoryOptions]="clientGeneralStep?.socialCategoryOptions || []"
(submitEvent)="submit()"
></mifosx-client-preview-step>
</div>
</mat-tab>
@@ -350,9 +351,11 @@
<!-- Bottom Action Bar -->
<div class="action-bar">
<button mat-raised-button [routerLink]="['../']" class="cancel-btn">
{{ 'labels.buttons.Cancel' | translate }}
</button>
@if (selectedTabIndex < totalTabs - 1) {
<button mat-raised-button [routerLink]="['../']" class="cancel-btn">
{{ 'labels.buttons.Cancel' | translate }}
</button>
}
<div class="action-bar-right">
@if (selectedTabIndex > 0) {
<button mat-raised-button (click)="selectedTabIndex = selectedTabIndex - 1">
@@ -361,15 +364,7 @@
</button>
}
@if (selectedTabIndex < totalTabs - 1) {
<button
mat-raised-button
color="primary"
(click)="onNext()"
[disabled]="!currentStepValid"
[matTooltip]="currentStepBlockerMessage"
[matTooltipDisabled]="currentStepValid"
matTooltipPosition="above"
>
<button mat-raised-button color="primary" (click)="onNext()" [disabled]="savingClient || !currentStepValid">
@if (savingClient) {
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
Saving...
@@ -383,15 +378,10 @@
}
</button>
}
@if (selectedTabIndex === totalTabs - 1 && areFormvalids()) {
<button mat-raised-button color="primary" (click)="submit()" [disabled]="savingClient">
@if (savingClient) {
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
Saving...
} @else {
<fa-icon icon="check" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Submit' | translate }}
}
@if (selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" [routerLink]="['../']">
<fa-icon icon="check" class="m-r-10"></fa-icon>
Finish
</button>
}
</div>
@@ -1021,6 +1021,9 @@ export class EditClientComponent {
* Mirrors identities-tab.createIdentifierWithDocs.
*/
private saveAddressProof(clientId: string) {
if (this.clientIndiaAddressStep?.existingAddressProofIdentifierId) {
return;
}
const payload = this.clientIndiaAddressStep?.addressProofPayload;
if (!payload?.proofId) return;
@@ -1036,6 +1039,9 @@ export class EditClientComponent {
next: (res: any) => {
const identifierId = res?.resourceId;
if (!identifierId) return;
if (this.clientIndiaAddressStep) {
this.clientIndiaAddressStep.existingAddressProofIdentifierId = identifierId.toString();
}
for (const entry of payload.files) {
const formData = new FormData();
formData.append('name', `${proofName} — ${entry.slot}`);
@@ -1056,6 +1062,21 @@ export class EditClientComponent {
if (!payload?.proofId || !payload?.files?.length) return;
const proofName = payload.proof?.name || 'KYC Document';
const uploadFiles = (identifierId: string) => {
for (const entry of payload.files) {
const formData = new FormData();
formData.append('name', `${proofName} — ${entry.slot}`);
formData.append('file', entry.file);
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
}
};
if (this.clientKycStep?.existingKycIdentifierId) {
uploadFiles(this.clientKycStep.existingKycIdentifierId);
return;
}
// The KYC step's chip-picker carries `id` as a doc-type CODE string (e.g. "AADHAAR").
// The backend ClientIdentifierRequest.documentTypeId is a Long, so a string proofId
// trips Jackson → HttpMessageNotReadableException → 400. Resolve to numeric CodeValue
@@ -1075,12 +1096,10 @@ export class EditClientComponent {
next: (res: any) => {
const identifierId = res?.resourceId;
if (!identifierId) return;
for (const entry of payload.files) {
const formData = new FormData();
formData.append('name', `${proofName} — ${entry.slot}`);
formData.append('file', entry.file);
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
if (this.clientKycStep) {
this.clientKycStep.existingKycIdentifierId = identifierId.toString();
}
uploadFiles(identifierId.toString());
},
error: () => {}
});
@@ -1098,8 +1117,45 @@ export class EditClientComponent {
return this.clientsService.getKycProofTypes('id').pipe(
map((res: any) => {
const list: any[] = res?.idProofs || [];
const targetName = (proofName || '').trim().toLowerCase();
const match = list.find((p: any) => (p?.name || '').trim().toLowerCase() === targetName);
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const target = cleanStr(proofName || proofId);
// 1. Try exact code/name match
let match = list.find((p: any) => {
const pcode = cleanStr(p.code);
const pname = cleanStr(p.name);
return pcode === target || pname === target;
});
// 2. Try loose contains/includes match
if (!match) {
match = list.find((p: any) => {
const pcode = cleanStr(p.code);
const pname = cleanStr(p.name);
return pcode.includes(target) || pname.includes(target) || target.includes(pcode) || target.includes(pname);
});
}
// 3. Specific fallbacks for common keywords
if (!match && target.includes('voter')) {
match = list.find((p: any) => {
const name = cleanStr(p.name);
return name.includes('voter') || name.includes('epic');
});
}
if (!match && target.includes('aadhaar')) {
match = list.find((p: any) => cleanStr(p.name).includes('aadhaar'));
}
if (!match && target.includes('passport')) {
match = list.find((p: any) => cleanStr(p.name).includes('passport'));
}
if (!match && target.includes('driving')) {
match = list.find((p: any) => cleanStr(p.name).includes('drive') || cleanStr(p.name).includes('licen'));
}
if (!match && target.includes('pan')) {
match = list.find((p: any) => cleanStr(p.name).includes('pan'));
}
return (match?.id ?? null) as number | null;
})
);
@@ -0,0 +1,136 @@
/**
* 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 {
deriveKycDocumentRenewal,
kycDocumentNeedsAttention,
KYC_DOCUMENT_EXPIRY_WARNING_DAYS,
KYC_DOCUMENT_RENEWAL_YEARS
} from './kyc-document-renewal.util';
const TODAY = new Date(2026, 7, 7); // 07 Aug 2026
const at = (y: number, m: number, d: number) => new Date(y, m - 1, d);
const check = (issueDate: Date | null, expiryDate: Date | null = null, today: Date = TODAY) =>
deriveKycDocumentRenewal({ issueDate, expiryDate, today });
describe('deriveKycDocumentRenewal', () => {
describe('the five-year rule', () => {
it('says nothing about a document with neither date captured', () => {
const result = check(null, null);
expect(result.state).toBe('UNKNOWN');
expect(result.dueOn).toBeNull();
expect(result.daysRemaining).toBeNull();
});
it('leaves a recently issued document alone', () => {
const result = check(at(2025, 1, 15));
expect(result.state).toBe('OK');
expect(result.reason).toBe('AGE');
});
it('falls due exactly five years after issue', () => {
const result = check(at(2021, 8, 7));
expect(result.dueOn).toEqual(at(2026, 8, 7));
expect(result.daysRemaining).toBe(0);
// Due today is not yet past — the branch still has the day to act.
expect(result.state).toBe('OK');
});
it('becomes DUE the day after the fifth anniversary', () => {
const result = check(at(2021, 8, 6));
expect(result.state).toBe('DUE');
expect(result.reason).toBe('AGE');
});
it('is DUE, not EXPIRED, once past five years — the document is still valid, just stale', () => {
const result = check(at(2020, 3, 10));
expect(result.state).toBe('DUE');
expect(result.reason).toBe('AGE');
expect(result.daysRemaining).toBeLessThan(0);
});
it('does not warn ahead of the five-year mark — a document one day short is entirely fine', () => {
// Due tomorrow on age alone, and still OK: only expiry gets an advance warning.
const result = check(at(2021, 8, 8));
expect(result.daysRemaining).toBe(1);
expect(result.state).toBe('OK');
});
});
describe('an explicit expiry date', () => {
it('warns seven days before expiry', () => {
const result = check(at(2025, 6, 1), at(2026, 8, 14));
expect(result.reason).toBe('EXPIRY');
expect(result.daysRemaining).toBe(7);
expect(result.state).toBe('EXPIRING_SOON');
});
it('is still OK eight days out', () => {
expect(check(at(2025, 6, 1), at(2026, 8, 15)).state).toBe('OK');
});
it('warns on the day of expiry itself', () => {
const result = check(at(2025, 6, 1), at(2026, 8, 7));
expect(result.daysRemaining).toBe(0);
expect(result.state).toBe('EXPIRING_SOON');
});
it('marks a passed expiry as EXPIRED — no longer a valid OVD', () => {
const result = check(at(2024, 1, 1), at(2026, 1, 1));
expect(result.state).toBe('EXPIRED');
expect(result.reason).toBe('EXPIRY');
});
it('governs when it falls before the five-year mark', () => {
// Issued last year, so the age clock is years away — expiry is what matters.
const result = check(at(2025, 6, 1), at(2026, 10, 1));
expect(result.reason).toBe('EXPIRY');
expect(result.dueOn).toEqual(at(2026, 10, 1));
});
it('yields to the five-year rule when it falls later', () => {
// A passport issued in 2019 expiring in 2029 is stale long before it expires.
const result = check(at(2019, 5, 20), at(2029, 5, 20));
expect(result.reason).toBe('AGE');
expect(result.dueOn).toEqual(at(2024, 5, 20));
expect(result.state).toBe('DUE');
});
it('is judged on expiry alone when no issue date was captured', () => {
const result = check(null, at(2027, 1, 1));
expect(result.reason).toBe('EXPIRY');
expect(result.state).toBe('OK');
});
});
describe('leap-year issue dates', () => {
it('pins a 29 February issue to 28 February, not 1 March', () => {
// 2020-02-29 + 5 years has no real anniversary; rolling into March would push the
// document into the wrong month.
const result = check(at(2020, 2, 29));
expect(result.dueOn).toEqual(at(2025, 2, 28));
});
});
describe('kycDocumentNeedsAttention', () => {
it('flags only the states staff must act on', () => {
expect(kycDocumentNeedsAttention('UNKNOWN')).toBe(false);
expect(kycDocumentNeedsAttention('OK')).toBe(false);
expect(kycDocumentNeedsAttention('EXPIRING_SOON')).toBe(true);
expect(kycDocumentNeedsAttention('EXPIRED')).toBe(true);
expect(kycDocumentNeedsAttention('DUE')).toBe(true);
});
});
it('uses a five-year age window and a seven-day expiry warning', () => {
expect(KYC_DOCUMENT_RENEWAL_YEARS).toBe(5);
expect(KYC_DOCUMENT_EXPIRY_WARNING_DAYS).toBe(7);
});
});
@@ -0,0 +1,152 @@
/**
* 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/.
*/
/**
* Decides when the officially valid document (OVD) captured at KYC needs updating.
*
* The KYC step records a document type, its number, an issue date and — for documents that carry
* one — an expiry date. Nothing has ever read those dates back, so a client onboarded on a
* ten-year-old driving licence looked exactly like one onboarded yesterday. This module turns the
* two dates into a single answer to "does this document need updating?", so the KYC tab can say so
* rather than leaving staff to work it out from a date on a form.
*
* Two separate clocks run, and they warn differently because they mean different things:
*
* - **Age.** Five years after issue the document is stale and must be re-collected. It is still a
* valid document, so there is nothing to pre-empt — the alert appears once the date passes.
* - **Expiry.** A printed expiry date is a hard stop: the day after, the document is not a valid
* OVD at all and the account is out of compliance. That one is worth heading off, so it warns
* seven days ahead while the customer can still be asked to bring the renewed document in.
*/
/** A document is treated as due for refresh five years after it was issued. */
export const KYC_DOCUMENT_RENEWAL_YEARS = 5;
/** How many days before a printed expiry date the "expires soon" warning starts. */
export const KYC_DOCUMENT_EXPIRY_WARNING_DAYS = 7;
export type KycDocumentRenewalState =
/** No dates captured — nothing can be said about this document. */
| 'UNKNOWN'
/** Within its life, and not close to either threshold. */
| 'OK'
/** Printed expiry date falls within the next seven days. */
| 'EXPIRING_SOON'
/** Past its printed expiry date: no longer a valid OVD. */
| 'EXPIRED'
/** Past the five-year mark: still a valid document, but must be re-collected. */
| 'DUE';
/** Which of the two clocks decided the outcome. */
export type KycDocumentRenewalReason = 'EXPIRY' | 'AGE' | null;
export interface KycDocumentRenewalInput {
/** Issue date printed on the document. */
issueDate: Date | null;
/** Expiry date printed on the document — only some carry one (passport, DL, visa). */
expiryDate: Date | null;
/** Today, injected so the calculation is testable and matches the tenant's date. */
today: Date;
}
export interface KycDocumentRenewalStatus {
state: KycDocumentRenewalState;
/** The date the document falls due — the earlier of its expiry and its fifth anniversary. */
dueOn: Date | null;
/** Days until `dueOn`; negative once it has passed. Null when nothing can be judged. */
daysRemaining: number | null;
reason: KycDocumentRenewalReason;
}
/** Midnight-aligned, so a document due today is not counted as a fraction of a day overdue. */
function startOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
/**
* Adds whole years, clamping to the end of the month.
*
* A 29 February issue date has no fifth anniversary. Letting the Date constructor roll it into
* 1 March would push the document into the wrong month, so it is pinned to 28 February instead.
*/
function addYears(date: Date, years: number): Date {
const target = new Date(date.getFullYear() + years, date.getMonth(), date.getDate());
if (target.getMonth() !== date.getMonth()) {
return new Date(target.getFullYear(), target.getMonth(), 0);
}
return target;
}
const MS_PER_DAY = 24 * 60 * 60 * 1000;
function daysBetween(from: Date, to: Date): number {
return Math.round((to.getTime() - from.getTime()) / MS_PER_DAY);
}
/**
* Works out whether the captured document needs updating.
*
* The two dates are not alternatives — whichever falls first governs. A passport issued last year
* but expiring in six months is due in six months, not in four years: treating the five-year rule
* as the only clock would leave an expired passport on file looking current. A document with no
* printed expiry is judged purely on age.
*/
export function deriveKycDocumentRenewal(input: KycDocumentRenewalInput): KycDocumentRenewalStatus {
const today = startOfDay(input.today);
const issue = input.issueDate ? startOfDay(input.issueDate) : null;
const expiry = input.expiryDate ? startOfDay(input.expiryDate) : null;
if (!issue && !expiry) {
return { state: 'UNKNOWN', dueOn: null, daysRemaining: null, reason: null };
}
const ageDue = issue ? addYears(issue, KYC_DOCUMENT_RENEWAL_YEARS) : null;
// Whichever comes first governs — see the doc comment above.
let dueOn: Date;
let reason: KycDocumentRenewalReason;
if (expiry && ageDue) {
if (expiry.getTime() <= ageDue.getTime()) {
dueOn = expiry;
reason = 'EXPIRY';
} else {
dueOn = ageDue;
reason = 'AGE';
}
} else if (expiry) {
dueOn = expiry;
reason = 'EXPIRY';
} else {
dueOn = ageDue as Date;
reason = 'AGE';
}
const daysRemaining = daysBetween(today, dueOn);
let state: KycDocumentRenewalState;
if (reason === 'EXPIRY') {
if (daysRemaining < 0) {
state = 'EXPIRED';
} else if (daysRemaining <= KYC_DOCUMENT_EXPIRY_WARNING_DAYS) {
state = 'EXPIRING_SOON';
} else {
state = 'OK';
}
} else {
// Age alone: a document one day short of five years is entirely fine, so there is nothing to
// warn about in advance. It becomes due the moment the anniversary passes.
state = daysRemaining < 0 ? 'DUE' : 'OK';
}
return { state, dueOn, daysRemaining, reason };
}
/** Whether this state warrants surfacing an alert to staff at all. */
export function kycDocumentNeedsAttention(state: KycDocumentRenewalState): boolean {
return state === 'EXPIRING_SOON' || state === 'EXPIRED' || state === 'DUE';
}
+111
View File
@@ -93,6 +93,117 @@ describe('deriveKycExceptionFields', () => {
});
});
describe('PAN declaration', () => {
it('waives the PAN exception once a declaration is taken', () => {
// The client's name/DOB will never match the PAN record, so the API can never pass. The
// signed declaration closes the item instead — the client must not sit in the exception list.
expect(
deriveKycExceptionFields({
...newIndividual(false, true),
panDeclarationTaken: true
})
).toEqual({
isException: false,
exceptionReason: null,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('still raises the PAN exception when no declaration has been taken', () => {
expect(
deriveKycExceptionFields({
...newIndividual(false, true),
panDeclarationTaken: false
}).exceptionReason
).toBe(KYC_EXCEPTION_PAN);
});
it('leaves an outstanding Aadhaar untouched — a PAN declaration covers PAN only', () => {
const result = deriveKycExceptionFields({
...newIndividual(false, false),
panDeclarationTaken: true
});
expect(result.isException).toBe(true);
expect(result.exceptionReason).toBe(KYC_EXCEPTION_AADHAAR);
});
it('closes an existing PAN exception when a declaration is taken afterwards', () => {
expect(
deriveKycExceptionFields({
...existingException(false, true, KYC_EXCEPTION_PAN),
panDeclarationTaken: true
})
).toEqual({
isException: false,
exceptionReason: KYC_EXCEPTION_PAN,
exceptionResolved: true,
exceptionResolvedOn: TODAY
});
});
});
describe('Aadhaar enrolment receipt', () => {
it('waives the Aadhaar exception once the enrolment receipt is recorded', () => {
// The client holds no Aadhaar yet, so there is no number to run the OTP check against. The
// UIDAI enrolment slip closes the item instead.
expect(
deriveKycExceptionFields({
...newIndividual(true, false),
aadhaarReceiptTaken: true
})
).toEqual({
isException: false,
exceptionReason: null,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('still raises the Aadhaar exception when no receipt has been taken', () => {
expect(
deriveKycExceptionFields({
...newIndividual(true, false),
aadhaarReceiptTaken: false
}).exceptionReason
).toBe(KYC_EXCEPTION_AADHAAR);
});
it('leaves an outstanding PAN untouched — a receipt covers Aadhaar only', () => {
const result = deriveKycExceptionFields({
...newIndividual(false, false),
aadhaarReceiptTaken: true
});
expect(result.isException).toBe(true);
expect(result.exceptionReason).toBe(KYC_EXCEPTION_PAN);
});
it('closes an existing Aadhaar exception when a receipt is taken afterwards', () => {
expect(
deriveKycExceptionFields({
...existingException(true, false, KYC_EXCEPTION_AADHAAR),
aadhaarReceiptTaken: true
})
).toEqual({
isException: false,
exceptionReason: KYC_EXCEPTION_AADHAAR,
exceptionResolved: true,
exceptionResolvedOn: TODAY
});
});
it('clears both items when a PAN declaration and an Aadhaar receipt are both on file', () => {
// Neither check can pass, yet the branch holds paperwork for both — nothing is outstanding.
const result = deriveKycExceptionFields({
...newIndividual(false, false),
panDeclarationTaken: true,
aadhaarReceiptTaken: true
});
expect(result.isException).toBe(false);
expect(result.exceptionReason).toBeNull();
});
});
describe('resolving', () => {
it('closes the exception once both checks pass, keeping the reason for the audit trail', () => {
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
+25 -2
View File
@@ -29,8 +29,28 @@ export const AUTO_MANAGED_KYC_EXCEPTION_REASONS: string[] = [
export interface KycExceptionInput {
/** PAN verified against the Income Tax database. Unverified OR simply not entered → exception. */
panVerified: boolean;
/**
* A signed customer declaration has been accepted in place of a passing PAN check.
*
* The Income Tax check matches on name *and* date of birth, so a client whose PAN card carries a
* different spelling — or a different DOB — can never clear it, however many times the officer
* retries. Once the branch holds a declaration recording exactly what differs, the outstanding
* item is closed by paperwork rather than by the API, and it must not keep the client sitting in
* the exception list. PAN still reads as unverified everywhere else; only the exception is waived.
*/
panDeclarationTaken?: boolean;
/** Aadhaar verified against UIDAI. Unverified OR simply not entered → exception. */
aadhaarVerified: boolean;
/**
* The customer's Aadhaar enrolment slip has been sighted and recorded.
*
* A customer who has applied for Aadhaar but has not been issued one yet holds only a UIDAI
* enrolment receipt. There is no Aadhaar number to put through the OTP flow, so the check cannot
* pass however long the branch waits — the same dead end the PAN declaration exists to resolve.
* Once the receipt is on file the outstanding item is closed by paperwork, and the client must
* not keep sitting in the exception list. Aadhaar still reads as unverified everywhere else.
*/
aadhaarReceiptTaken?: boolean;
/**
* False for non-individual clients, where no Aadhaar is collected (the payload sends a dummy
* masked value). Without this guard every company would carry a permanent Aadhaar exception.
@@ -69,10 +89,13 @@ export interface KycExceptionFields {
export function deriveKycExceptionFields(input: KycExceptionInput): KycExceptionFields {
const outstanding: string[] = [];
if (!input.panVerified) {
// A declaration settles the PAN item without the API ever passing — see `panDeclarationTaken`.
if (!input.panVerified && !input.panDeclarationTaken) {
outstanding.push(KYC_EXCEPTION_PAN);
}
if (input.aadhaarApplicable && !input.aadhaarVerified) {
// An enrolment receipt settles the Aadhaar item when the OTP check has nothing to run against —
// see `aadhaarReceiptTaken`.
if (input.aadhaarApplicable && !input.aadhaarVerified && !input.aadhaarReceiptTaken) {
outstanding.push(KYC_EXCEPTION_AADHAAR);
}
@@ -0,0 +1,84 @@
<!--
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="aad-rcpt">
<div class="aad-rcpt__header">
<mat-icon class="aad-rcpt__header-icon">receipt_long</mat-icon>
<div>
<div class="aad-rcpt__title">Aadhaar Enrolment Receipt</div>
<div class="aad-rcpt__subtitle">{{ data.clientName || 'Client' }}</div>
</div>
</div>
<mat-dialog-content class="aad-rcpt__body">
<div class="aad-rcpt__reason">
<mat-icon>info</mat-icon>
<span>{{ data.reason }}</span>
</div>
<p class="aad-rcpt__intro">
For a customer who has applied for Aadhaar but has not been issued one yet, there is no Aadhaar number to run the
OTP check against. Record the UIDAI enrolment slip instead — the client can then be created without an Aadhaar KYC
exception being raised.
</p>
<mat-form-field appearance="outline" class="aad-rcpt__field">
<mat-label>Enrolment number (EID)</mat-label>
<span matPrefix class="aad-rcpt__prefix"><mat-icon>confirmation_number</mat-icon></span>
<input matInput [(ngModel)]="enrolmentId" placeholder="1234/56789/01234" maxlength="20" autocomplete="off" />
<mat-hint [class.aad-rcpt__hint--bad]="enrolmentIdEntered && !enrolmentIdValid">
{{ enrolmentIdHint }}
</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="aad-rcpt__field">
<mat-label>Enrolment date</mat-label>
<input matInput [matDatepicker]="enrolPicker" [max]="today" [(ngModel)]="enrolmentDate" />
<mat-datepicker-toggle matSuffix [for]="enrolPicker"></mat-datepicker-toggle>
<mat-datepicker #enrolPicker></mat-datepicker>
<mat-hint>Date printed on the slip</mat-hint>
</mat-form-field>
@if (monthsSinceEnrolment !== null) {
<div class="aad-rcpt__age" [class.aad-rcpt__age--stale]="receiptIsStale">
<mat-icon>{{ receiptIsStale ? 'warning' : 'schedule' }}</mat-icon>
<span>
@if (receiptIsStale) {
Enrolled {{ monthsSinceEnrolment }} months ago. An enrolment normally completes well inside six months —
chase the customer for the Aadhaar itself. The receipt can still be recorded.
} @else {
Enrolled {{ monthsSinceEnrolment }} month{{ monthsSinceEnrolment === 1 ? '' : 's' }} ago.
}
</span>
</div>
}
<mat-form-field appearance="outline" class="aad-rcpt__remarks">
<mat-label>Remarks</mat-label>
<textarea
matInput
rows="3"
[(ngModel)]="remarks"
maxlength="500"
placeholder="What was sighted, and when the Aadhaar is expected"
></textarea>
<mat-hint>{{ remarks.length }}/500</mat-hint>
</mat-form-field>
<mat-checkbox class="aad-rcpt__sighted" [(ngModel)]="sighted">
The original UIDAI enrolment slip has been sighted and a copy filed with the account opening form.
</mat-checkbox>
</mat-dialog-content>
<mat-dialog-actions align="end" class="aad-rcpt__actions">
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
<button mat-flat-button color="primary" type="button" [disabled]="!canSubmit" (click)="submit()">
Save Receipt
</button>
</mat-dialog-actions>
</div>
@@ -0,0 +1,136 @@
/**
* 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/.
*/
.aad-rcpt {
max-width: 560px;
}
.aad-rcpt__header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 24px 12px;
border-bottom: 1px solid #bfdbfe;
background: #eff6ff;
}
.aad-rcpt__header-icon {
color: #2563eb;
flex: 0 0 auto;
}
.aad-rcpt__title {
font-size: 16px;
font-weight: 600;
color: #1e3a8a;
}
.aad-rcpt__subtitle {
font-size: 12px;
color: #2563eb;
}
.aad-rcpt__body {
padding: 18px 24px !important;
font-size: 14px;
color: #475569;
}
.aad-rcpt__reason {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
margin-bottom: 14px;
border-radius: 6px;
background: #f8fafc;
border: 1px solid #e2e8f0;
color: #475569;
font-size: 13px;
line-height: 1.5;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex: 0 0 auto;
color: #64748b;
}
}
.aad-rcpt__intro {
margin: 0 0 16px;
font-size: 13px;
line-height: 1.6;
}
.aad-rcpt__field,
.aad-rcpt__remarks {
width: 100%;
}
.aad-rcpt__prefix {
display: inline-flex;
align-items: center;
margin-right: 8px;
color: #94a3b8;
}
.aad-rcpt__hint--bad {
color: #dc2626;
}
// Age of the slip — informational, so it reads as context rather than a blocking error.
.aad-rcpt__age {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 2px 0 14px;
padding: 9px 12px;
border-radius: 6px;
background: #f8fafc;
border: 1px solid #e2e8f0;
font-size: 12.5px;
line-height: 1.5;
color: #475569;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex: 0 0 auto;
color: #64748b;
}
&--stale {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
mat-icon {
color: #d97706;
}
}
}
.aad-rcpt__sighted {
display: block;
margin-top: 4px;
font-size: 13px;
line-height: 1.5;
::ng-deep .mdc-form-field > label {
white-space: normal;
}
}
.aad-rcpt__actions {
padding: 10px 24px 16px !important;
margin: 0 !important;
gap: 8px;
}
@@ -0,0 +1,151 @@
/**
* 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 { FormsModule } from '@angular/forms';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export interface AadhaarReceiptDialogData {
/** Client name, shown so the officer can check the slip belongs to the right person. */
clientName: string;
/** Message the Aadhaar step came back with, or why the officer is recording a receipt. */
reason: string;
/** Existing receipt, when the officer re-opens one already taken. */
existing?: AadhaarReceiptResult | null;
}
export interface AadhaarReceiptResult {
/** 14-digit enrolment number as printed on the slip, separators kept. */
enrolmentId: string;
enrolmentDate: Date | null;
remarks: string;
}
/**
* Records the UIDAI enrolment receipt accepted in place of a verified Aadhaar.
*
* A customer who has applied for Aadhaar but has not been issued one holds only an enrolment slip.
* There is no Aadhaar number to send to the OTP flow, so no amount of retrying will clear the
* check — the same dead end the PAN declaration resolves. Recording the slip lets the client be
* created and waives the Aadhaar exception, while Aadhaar itself stays unverified.
*
* Only the 14-digit enrolment number is taken. The date and time printed beside it on the slip are
* captured by the enrolment-date field, so asking for them again in the same box would just be a
* second chance to mistype them.
*
* The number is validated on digit count rather than an exact layout: slips print it with varying
* separators, and rejecting one because of a slash or a space would block a customer over
* formatting rather than substance.
*/
@Component({
selector: 'mifosx-aadhaar-receipt-dialog',
templateUrl: './aadhaar-receipt-dialog.component.html',
styleUrls: ['./aadhaar-receipt-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
MatIcon,
MatDialogContent,
MatDialogActions,
MatDialogClose
]
})
export class AadhaarReceiptDialogComponent {
data = inject<AadhaarReceiptDialogData>(MAT_DIALOG_DATA);
private dialogRef = inject(MatDialogRef<AadhaarReceiptDialogComponent>);
enrolmentId = '';
enrolmentDate: Date | null = null;
remarks = '';
/** The officer confirms the original slip was sighted — nothing is saved without it. */
sighted = false;
today = new Date();
constructor() {
const existing = this.data.existing;
if (existing) {
this.enrolmentId = existing.enrolmentId || '';
this.enrolmentDate = existing.enrolmentDate || null;
this.remarks = existing.remarks || '';
}
}
/** Digits only — separators on the printed slip are cosmetic and vary between forms. */
private get enrolmentDigits(): string {
return (this.enrolmentId || '').replace(/\D/g, '');
}
get enrolmentIdEntered(): boolean {
return this.enrolmentDigits.length > 0;
}
/** UIDAI's enrolment number is 14 digits — the timestamp beside it is not part of it. */
get enrolmentIdValid(): boolean {
return this.enrolmentDigits.length === 14;
}
/** How many digits are still missing, so the officer is told what to fix, not just that it failed. */
get enrolmentIdHint(): string {
const count = this.enrolmentDigits.length;
if (!count) {
return 'The 14-digit enrolment number only, e.g. 1234/56789/01234';
}
return `${count} of 14 digits`;
}
/** A slip dated in the future is a keying error, not a valid enrolment. */
get enrolmentDateValid(): boolean {
return !!this.enrolmentDate && this.enrolmentDate.getTime() <= this.today.getTime();
}
/** Whole months since enrolment — shown because a receipt is temporary by nature. */
get monthsSinceEnrolment(): number | null {
if (!this.enrolmentDate) {
return null;
}
const months =
(this.today.getFullYear() - this.enrolmentDate.getFullYear()) * 12 +
(this.today.getMonth() - this.enrolmentDate.getMonth()) -
(this.today.getDate() < this.enrolmentDate.getDate() ? 1 : 0);
return Math.max(0, months);
}
/**
* An enrolment normally completes well inside six months, so an older slip is a prompt to chase
* the customer for the Aadhaar itself. It is a warning, not a block: the branch may have good
* reason to accept it, and the record shows the age either way.
*/
get receiptIsStale(): boolean {
const months = this.monthsSinceEnrolment;
return months !== null && months >= 6;
}
get canSubmit(): boolean {
return this.enrolmentIdValid && this.enrolmentDateValid && !!this.remarks.trim() && this.sighted;
}
submit(): void {
if (!this.canSubmit) {
return;
}
this.dialogRef.close({
enrolmentId: this.enrolmentId.trim(),
enrolmentDate: this.enrolmentDate,
remarks: this.remarks.trim()
} as AadhaarReceiptResult);
}
}
@@ -84,6 +84,121 @@ export class KycUploadHelperService {
);
}
/**
* Split a stacked front-above-back scan back into its two sides.
*
* Clients saved before the sides were stored separately carry one merged PNG, so a form that
* wants a front image and a back image has nothing to show in the second slot. `mergeFrontBack`
* lays the sides on a white canvas with a 20px gap between them, which leaves a band of
* completely white rows across the full width — that band is the seam, and it is the only thing
* in the file that says where one side ends and the other begins.
*
* Resolves null when no such seam is found, which is the honest answer for an image that was not
* produced by that merge: better to show one scan than to cut a document in half at a guess.
*/
splitFrontBack(blob: Blob): Promise<{ front: Blob; back: Blob } | null> {
if (!blob.type.startsWith('image/')) {
return Promise.resolve(null);
}
type Sides = { front: Blob; back: Blob } | null;
return this.blobToImage(blob)
.then((img): Sides | Promise<Sides> => {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
const ctx = canvas.getContext('2d');
if (!ctx || canvas.width === 0 || canvas.height === 0) {
return null;
}
ctx.drawImage(img, 0, 0);
const seam = this.findWhiteSeam(ctx, canvas.width, canvas.height);
if (!seam) {
return null;
}
return Promise.all([
this.cropToBlob(img, canvas.width, 0, seam.start),
this.cropToBlob(img, canvas.width, seam.end, canvas.height - seam.end)
]).then(
([
front,
back
]): Sides => (front && back ? { front, back } : null)
);
})
.catch((): Sides => null);
}
/**
* The tallest run of all-white rows in the middle of the image.
*
* Confined to the middle half because a scan often has white margins at the top and bottom, and
* those are part of the document rather than the join between two of them.
*/
private findWhiteSeam(
ctx: CanvasRenderingContext2D,
width: number,
height: number
): { start: number; end: number } | null {
const from = Math.floor(height * 0.25);
const to = Math.ceil(height * 0.75);
const step = Math.max(1, Math.floor(width / 64)); // sampling is enough to spot a non-white row
let bestStart = -1;
let bestLength = 0;
let runStart = -1;
for (let y = from; y <= to; y++) {
let white = y < to;
if (white) {
const row = ctx.getImageData(0, y, width, 1).data;
for (let x = 0; x < width; x += step) {
const i = x * 4;
if (row[i] < 245 || row[i + 1] < 245 || row[i + 2] < 245) {
white = false;
break;
}
}
}
if (white) {
if (runStart < 0) {
runStart = y;
}
} else if (runStart >= 0) {
if (y - runStart > bestLength) {
bestStart = runStart;
bestLength = y - runStart;
}
runStart = -1;
}
}
// The gap the merge inserts is 20px; anything much thinner is white space inside a document.
return bestLength >= 12 ? { start: bestStart, end: bestStart + bestLength } : null;
}
private cropToBlob(img: HTMLImageElement, width: number, top: number, height: number): Promise<Blob | null> {
if (height <= 0) {
return Promise.resolve(null);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
return Promise.resolve(null);
}
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, top, width, height, 0, 0, width, height);
return new Promise((resolve) => canvas.toBlob((out) => resolve(out), 'image/png'));
}
private blobToImage(blob: Blob): Promise<HTMLImageElement> {
return this.fileToImage(blob as File);
}
private fileToImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
@@ -0,0 +1,176 @@
<!--
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="pan-decl">
<div class="pan-decl__header">
<mat-icon class="pan-decl__header-icon">assignment_late</mat-icon>
<div>
<div class="pan-decl__title">PAN Verification Failed — Take Declaration</div>
<div class="pan-decl__subtitle">PAN {{ data.panNumber }}</div>
</div>
</div>
<mat-dialog-content class="pan-decl__body">
<div class="pan-decl__reason">
<mat-icon>info</mat-icon>
<span>{{ data.failureMessage }}</span>
</div>
@if (!data.unverifiable) {
<p class="pan-decl__intro">
The Income Tax check matches on <strong>name and date of birth together</strong> and does not report which of
the two differs. Enter the details exactly as printed on the customer's PAN card so the declaration records what
actually does not match.
</p>
<!-- Comparison: what we submitted vs what the PAN card says -->
<div class="pan-decl__compare">
<div class="pan-decl__compare-head">
<span class="lbl"></span>
<span class="col">Submitted</span>
<span class="col">As on PAN card</span>
<span class="res"></span>
</div>
<div class="pan-decl__compare-row" [class.mismatch]="nameMismatch">
<span class="lbl">Name</span>
<span class="col submitted">{{ data.submittedName || '—' }}</span>
<span class="col">
<mat-form-field appearance="outline" class="pan-decl__field">
<mat-label>Name on PAN</mat-label>
<input
matInput
[(ngModel)]="nameOnPan"
(ngModelChange)="onCompare()"
placeholder="e.g. RAJESH KUMAR SHARMA"
maxlength="200"
autocomplete="off"
/>
</mat-form-field>
</span>
<span class="res">
@if (compared && nameOnPan.trim()) {
@if (nameMismatch) {
<mat-icon class="res-icon res-icon--bad" aria-label="Does not match">cancel</mat-icon>
} @else {
<mat-icon class="res-icon res-icon--ok" aria-label="Matches">check_circle</mat-icon>
}
}
</span>
</div>
<div class="pan-decl__compare-row" [class.mismatch]="dobMismatch">
<span class="lbl">Date of Birth</span>
<span class="col submitted">{{ data.submittedDob || '—' }}</span>
<span class="col">
<mat-form-field appearance="outline" class="pan-decl__field">
<mat-label>DOB on PAN</mat-label>
<input
matInput
[matDatepicker]="dobPicker"
[max]="today"
[(ngModel)]="dobOnPan"
(ngModelChange)="onCompare()"
/>
<mat-datepicker-toggle matSuffix [for]="dobPicker"></mat-datepicker-toggle>
<mat-datepicker #dobPicker></mat-datepicker>
</mat-form-field>
</span>
<span class="res">
@if (compared && dobOnPan) {
@if (dobMismatch) {
<mat-icon class="res-icon res-icon--bad" aria-label="Does not match">cancel</mat-icon>
} @else {
<mat-icon class="res-icon res-icon--ok" aria-label="Matches">check_circle</mat-icon>
}
}
</span>
</div>
</div>
<!-- What the declaration will state -->
<div
class="pan-decl__verdict"
[class.pan-decl__verdict--pending]="!comparisonComplete"
[class.pan-decl__verdict--clean]="nothingToDeclare"
>
<span class="pan-decl__verdict-label">Does not match:</span>
<span class="pan-decl__verdict-value">{{ mismatchLabel }}</span>
</div>
@if (!comparisonComplete) {
<p class="pan-decl__note">Enter both PAN-card values to see exactly which detail differs.</p>
}
@if (nothingToDeclare) {
<p class="pan-decl__warn">
<mat-icon>warning</mat-icon>
The PAN-card details you entered are the same as the ones submitted, so there is nothing to declare. Correct
the client's name or date of birth on the General step and verify PAN again.
</p>
}
} @else {
<p class="pan-decl__intro">
PAN could not be checked against the Income Tax records. Record the declaration taken from the customer so the
client can be created without a KYC exception being raised for PAN.
</p>
}
<!-- The signed paper is the declaration; it is uploaded, not described. -->
<div class="pan-decl__upload">
<div class="pan-decl__upload-label">Signed declaration</div>
<p class="pan-decl__upload-help">
Get the declaration signed by the customer and upload the scanned copy. It is filed against the client as the
proof that holds the PAN exception off.
</p>
@if (!hasDeclarationFile) {
<label class="pan-decl__dropzone">
<input type="file" [accept]="acceptedTypes" (change)="onDeclarationFile($event)" hidden />
<mat-icon class="pan-decl__dropzone-icon">cloud_upload</mat-icon>
<span class="pan-decl__dropzone-text">Click to upload signed declaration</span>
<span class="pan-decl__dropzone-hint">PDF, JPG or PNG — up to 5 MB</span>
</label>
} @else {
<div class="pan-decl__file">
<mat-icon class="pan-decl__file-icon">description</mat-icon>
<span class="pan-decl__file-meta">
<span class="pan-decl__file-name">{{ fileName }}</span>
<span class="pan-decl__file-sub">{{ fileSizeLabel }}</span>
</span>
<label class="pan-decl__file-replace">
<input type="file" [accept]="acceptedTypes" (change)="onDeclarationFile($event)" hidden />
Replace
</label>
<button
type="button"
class="pan-decl__file-remove"
aria-label="Remove file"
(click)="removeDeclarationFile()"
>
<mat-icon>close</mat-icon>
</button>
</div>
}
@if (fileError) {
<p class="pan-decl__file-error">
<mat-icon>error_outline</mat-icon>
<span>{{ fileError }}</span>
</p>
}
</div>
</mat-dialog-content>
<mat-dialog-actions align="end" class="pan-decl__actions">
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
<button mat-flat-button color="primary" type="button" [disabled]="!canSubmit" (click)="submit()">
Save Declaration
</button>
</mat-dialog-actions>
</div>
@@ -0,0 +1,361 @@
/**
* 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/.
*/
.pan-decl {
max-width: 720px;
}
.pan-decl__header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 24px 12px;
border-bottom: 1px solid #fde68a;
background: #fffbeb;
}
.pan-decl__header-icon {
color: #d97706;
flex: 0 0 auto;
}
.pan-decl__title {
font-size: 16px;
font-weight: 600;
color: #92400e;
}
.pan-decl__subtitle {
font-size: 12px;
color: #b45309;
letter-spacing: 0.03em;
}
.pan-decl__body {
padding: 18px 24px !important;
font-size: 14px;
color: #475569;
}
.pan-decl__reason {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
margin-bottom: 14px;
border-radius: 6px;
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
font-size: 13px;
line-height: 1.5;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex: 0 0 auto;
}
}
.pan-decl__intro {
margin: 0 0 16px;
font-size: 13px;
line-height: 1.6;
}
// ── Submitted vs PAN-card comparison ───────────────────────────────────────
.pan-decl__compare {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.pan-decl__compare-head,
.pan-decl__compare-row {
display: grid;
grid-template-columns: 110px 1fr 1fr 36px;
gap: 12px;
align-items: center;
padding: 8px 14px;
}
.pan-decl__compare-head {
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.pan-decl__compare-row {
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: none;
}
// A row the declaration will name — tinted so the officer can see the finding at a glance.
&.mismatch {
background: #fff7ed;
}
.lbl {
font-size: 13px;
font-weight: 500;
color: #334155;
}
.submitted {
font-size: 13px;
color: #0f172a;
word-break: break-word;
}
}
.pan-decl__field {
width: 100%;
::ng-deep .mat-mdc-form-field-subscript-wrapper {
display: none;
}
}
.res {
display: flex;
justify-content: center;
}
.res-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
.res-icon--ok {
color: #16a34a;
}
.res-icon--bad {
color: #dc2626;
}
// ── Verdict ────────────────────────────────────────────────────────────────
.pan-decl__verdict {
display: flex;
align-items: baseline;
gap: 8px;
margin-top: 14px;
padding: 10px 14px;
border-radius: 6px;
background: #fef2f2;
border: 1px solid #fecaca;
&--pending {
background: #f8fafc;
border-color: #e2e8f0;
}
&--clean {
background: #f0fdf4;
border-color: #bbf7d0;
}
}
.pan-decl__verdict-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.pan-decl__verdict-value {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
.pan-decl__note {
margin: 8px 0 0;
font-size: 12px;
color: #64748b;
}
.pan-decl__warn {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 10px 0 0;
font-size: 12.5px;
line-height: 1.5;
color: #b45309;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex: 0 0 auto;
}
}
// ── Signed declaration upload ──────────────────────────────────────────────
.pan-decl__upload {
margin-top: 18px;
}
.pan-decl__upload-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.pan-decl__upload-help {
margin: 4px 0 10px;
font-size: 12.5px;
line-height: 1.5;
color: #64748b;
}
.pan-decl__dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 18px 24px;
background: #fff;
border: 2px dashed #cbd5e1;
border-radius: 10px;
cursor: pointer;
text-align: center;
transition: all 0.2s;
&:hover {
background: #f8fafc;
border-color: #93c5fd;
}
}
.pan-decl__dropzone-icon {
font-size: 28px;
width: 28px;
height: 28px;
color: #94a3b8;
}
.pan-decl__dropzone-text {
font-size: 13px;
font-weight: 600;
color: #475569;
}
.pan-decl__dropzone-hint {
font-size: 11px;
color: #94a3b8;
}
.pan-decl__file {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
}
.pan-decl__file-icon {
color: #16a34a;
flex: 0 0 auto;
}
.pan-decl__file-meta {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1 1 auto;
}
.pan-decl__file-name {
font-size: 13px;
font-weight: 600;
color: #166534;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pan-decl__file-sub {
font-size: 11px;
color: #4d7c69;
}
.pan-decl__file-replace {
flex: 0 0 auto;
padding: 4px 10px;
border: 1px solid #86efac;
border-radius: 6px;
background: #fff;
font-size: 12px;
font-weight: 600;
color: #166534;
cursor: pointer;
&:hover {
background: #ecfdf5;
}
}
.pan-decl__file-remove {
flex: 0 0 auto;
display: flex;
align-items: center;
padding: 4px;
border: none;
background: transparent;
color: #64748b;
cursor: pointer;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&:hover {
color: #dc2626;
}
}
.pan-decl__file-error {
display: flex;
align-items: flex-start;
gap: 6px;
margin: 8px 0 0;
font-size: 12.5px;
line-height: 1.5;
color: #b91c1c;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex: 0 0 auto;
}
}
.pan-decl__actions {
padding: 10px 24px 16px !important;
margin: 0 !important;
gap: 8px;
}
@@ -0,0 +1,271 @@
/**
* 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 { FormsModule } from '@angular/forms';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Which client details differ from the PAN card. */
export type PanMismatchField = 'NAME' | 'DOB';
export interface PanDeclarationDialogData {
/** Name submitted on the General step — the value sent to the Income Tax check. */
submittedName: string;
/** Date of birth submitted on the General step, in the tenant's display format. */
submittedDob: string;
/** PAN being declared against. */
panNumber: string;
/** Message the verification came back with, shown so the officer knows why they are here. */
failureMessage: string;
/**
* True when the check failed on something other than name/DOB (invalid PAN, vendor unreachable).
* The PAN-card fields then have nothing to compare against, so the mismatch reads NONE and the
* officer only has to record why the declaration is being taken.
*/
unverifiable?: boolean;
/** Existing declaration, when the officer re-opens one already taken. */
existing?: PanDeclarationResult | null;
}
export interface PanDeclarationResult {
/** 'NAME', 'DOB', 'NAME,DOB', or 'NONE' when PAN could not be checked at all. */
mismatchFields: string;
nameOnPan: string | null;
dobOnPan: Date | null;
/**
* The scanned signed declaration, to be filed against the client. Null when an existing
* declaration was re-opened and the officer did not replace the document already on file.
*/
file: File | null;
/** Name of the declaration document on record — what the KYC record keeps as the reference. */
fileName: string;
}
/**
* Records the customer declaration that stands in for a passing PAN check.
*
* The Income Tax API matches on name *and* date of birth and tells us only that the pair failed —
* never which half. So rather than show the officer a dead-end error, this dialog asks them to key
* in the two values printed on the physical PAN card in front of them. Comparing those against what
* was submitted is what produces a specific, defensible "NAME does not match" on the declaration,
* and the PAN-card values are kept alongside it as the evidence for that statement.
*
* Name comparison is deliberately loose about case and internal spacing — "RAJESH KUMAR" and
* "Rajesh Kumar" are the same name, and flagging them as a mismatch would put a declaration on file
* that says something untrue.
*
* The declaration itself is taken as an upload rather than typed: what waives the PAN exception is
* the paper the customer signed, so the scan of it is the record — an officer's typed note is not.
*/
@Component({
selector: 'mifosx-pan-declaration-dialog',
templateUrl: './pan-declaration-dialog.component.html',
styleUrls: ['./pan-declaration-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
MatIcon,
MatDialogContent,
MatDialogActions,
MatDialogClose
]
})
export class PanDeclarationDialogComponent {
data = inject<PanDeclarationDialogData>(MAT_DIALOG_DATA);
private dialogRef = inject(MatDialogRef<PanDeclarationDialogComponent>);
/** Name exactly as printed on the PAN card. */
nameOnPan = '';
/** Date of birth exactly as printed on the PAN card. */
dobOnPan: Date | null = null;
/** The scan the officer just picked. Null while an earlier upload is still the one on file. */
declarationFile: File | null = null;
/** Name of the document already filed, when re-opening a declaration that was taken before. */
existingFileName = '';
/** Why the picked file was rejected — shown in place of failing silently. */
fileError = '';
/** Officer has not yet typed anything to compare against. */
compared = false;
today = new Date();
readonly acceptedTypes = '.pdf,.jpg,.jpeg,.png';
private readonly maxFileBytes = 5 * 1024 * 1024;
private readonly acceptedExtensions = [
'pdf',
'jpg',
'jpeg',
'png'
];
constructor() {
const existing = this.data.existing;
if (existing) {
this.nameOnPan = existing.nameOnPan || '';
this.dobOnPan = existing.dobOnPan || null;
this.existingFileName = existing.fileName || '';
this.compared = true;
}
}
/** The document the declaration will be saved against — newly picked, or the one already filed. */
get fileName(): string {
return this.declarationFile?.name || this.existingFileName;
}
get hasDeclarationFile(): boolean {
return !!this.fileName;
}
/** Size of the picked scan, or a note that the document on file is being kept as it is. */
get fileSizeLabel(): string {
if (!this.declarationFile) {
return 'Already on file';
}
const kb = this.declarationFile.size / 1024;
return kb < 1024 ? `${Math.max(1, Math.round(kb))} KB` : `${(kb / 1024).toFixed(1)} MB`;
}
onDeclarationFile(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0] || null;
input.value = ''; // allow re-picking the same filename
if (!file) {
return;
}
const extension = (file.name.split('.').pop() || '').toLowerCase();
if (!this.acceptedExtensions.includes(extension)) {
this.fileError = 'Upload the signed declaration as a PDF, JPG or PNG.';
return;
}
if (file.size > this.maxFileBytes) {
this.fileError = 'The file is larger than 5 MB. Scan at a lower resolution and try again.';
return;
}
this.fileError = '';
this.declarationFile = file;
}
/** Drops the picked scan — and the one already filed, since neither may be left implied. */
removeDeclarationFile(): void {
this.declarationFile = null;
this.existingFileName = '';
this.fileError = '';
}
/** Case- and spacing-insensitive: only a genuinely different name counts as a mismatch. */
private normalizeName(value: string): string {
return (value || '').trim().replace(/\s+/g, ' ').toUpperCase();
}
private formatDob(value: Date | null): string {
if (!value) {
return '';
}
const dd = value.getDate().toString().padStart(2, '0');
const mm = (value.getMonth() + 1).toString().padStart(2, '0');
return `${dd}/${mm}/${value.getFullYear()}`;
}
get nameMismatch(): boolean {
if (this.data.unverifiable || !this.nameOnPan.trim()) {
return false;
}
return this.normalizeName(this.nameOnPan) !== this.normalizeName(this.data.submittedName);
}
get dobMismatch(): boolean {
if (this.data.unverifiable || !this.dobOnPan) {
return false;
}
// Compare on the rendered day/month/year so a time component on either value cannot matter.
return this.formatDob(this.dobOnPan) !== this.normalizeDisplayDob(this.data.submittedDob);
}
/** The submitted DOB arrives pre-formatted for display; reduce it to dd/MM/yyyy for comparison. */
private normalizeDisplayDob(display: string): string {
const parsed = new Date(display);
return isNaN(parsed.getTime()) ? (display || '').trim() : this.formatDob(parsed);
}
/** The fields the declaration will state as differing. */
get mismatchFields(): PanMismatchField[] {
const fields: PanMismatchField[] = [];
if (this.nameMismatch) {
fields.push('NAME');
}
if (this.dobMismatch) {
fields.push('DOB');
}
return fields;
}
get mismatchLabel(): string {
if (this.data.unverifiable) {
return 'PAN could not be verified';
}
const fields = this.mismatchFields;
if (!fields.length) {
return this.compared ? 'No difference found' : 'Not yet compared';
}
return fields.map((f) => (f === 'NAME' ? 'Name' : 'Date of Birth')).join(' and ');
}
/** Both PAN-card values must be keyed in before we can honestly say what differs. */
get comparisonComplete(): boolean {
return !!this.nameOnPan.trim() && !!this.dobOnPan;
}
onCompare(): void {
this.compared = true;
}
/**
* A declaration must actually declare something. When the officer keys in PAN-card values that
* match what was submitted, the mismatch is somewhere else — nothing to sign off here, and the
* right move is to retry the verification rather than paper over it.
*/
get nothingToDeclare(): boolean {
return !this.data.unverifiable && this.comparisonComplete && this.mismatchFields.length === 0;
}
get canSubmit(): boolean {
// The signed scan is the declaration — without it there is nothing to waive the exception on.
if (!this.hasDeclarationFile) {
return false;
}
if (this.data.unverifiable) {
return true;
}
return this.comparisonComplete && this.mismatchFields.length > 0;
}
submit(): void {
if (!this.canSubmit) {
return;
}
const result: PanDeclarationResult = {
mismatchFields: this.data.unverifiable ? 'NONE' : this.mismatchFields.join(','),
nameOnPan: this.data.unverifiable ? null : this.nameOnPan.trim(),
dobOnPan: this.data.unverifiable ? null : this.dobOnPan,
file: this.declarationFile,
fileName: this.fileName
};
this.dialogRef.close(result);
}
}
+6
View File
@@ -12,4 +12,10 @@
export interface Alert {
type: string;
message: string;
/**
* Errors are shown as a centred modal that has to be dismissed, because a corner toast that
* disappears on its own is too easy to miss for something the user has to act on. Anything
* without this keeps the unobtrusive snack bar.
*/
severity?: 'error';
}
+20 -24
View File
@@ -88,42 +88,38 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
throw response;
}
// Resolved once and emitted in one place below, so every failure here is consistently tagged
// as an error and therefore surfaces as a centred modal rather than a corner toast.
let alert: { type: string; message: string } | null = null;
if (status === 401 || (environment.oauth.enabled && status === 400)) {
this.alertService.alert({ type: 'Authentication Error', message: 'Invalid User Details. Please try again!' });
alert = { type: 'Authentication Error', message: 'Invalid User Details. Please try again!' };
} else if (status === 403 && errorMessage === 'The provided one time token is invalid') {
this.alertService.alert({ type: 'Invalid Token', message: 'Invalid Token. Please try again!' });
alert = { type: 'Invalid Token', message: 'Invalid Token. Please try again!' };
} else if (status === 400) {
this.alertService.alert({
type: 'Bad Request',
message: errorMessage || 'Invalid parameters were passed in the request!'
});
alert = { type: 'Bad Request', message: errorMessage || 'Invalid parameters were passed in the request!' };
} else if (status === 403) {
this.alertService.alert({
type: 'Unauthorized Request',
message: errorMessage || 'You are not authorized for this request!'
});
alert = { type: 'Unauthorized Request', message: errorMessage || 'You are not authorized for this request!' };
} else if (status === 404) {
{
this.alertService.alert({
type: this.translate.instant('error.resource.not.found'),
message: errorMessage || 'Resource does not exist!'
});
}
alert = {
type: this.translate.instant('error.resource.not.found'),
message: errorMessage || 'Resource does not exist!'
};
} else if (status === 500) {
this.alertService.alert({
type: 'Internal Server Error',
message: 'Internal Server Error. Please try again later.'
});
alert = { type: 'Internal Server Error', message: 'Internal Server Error. Please try again later.' };
} else if (status === 501) {
this.alertService.alert({
alert = {
type: this.translate.instant('error.resource.notImplemented.type'),
message: this.translate.instant('error.resource.notImplemented.message')
});
};
} else if (status === 503) {
this.alertService.alert({
alert = {
type: 'Service Unavailable',
message: errorMessage || 'The server is currently unable to handle the request. Please try after some time.'
});
};
}
if (alert) {
this.alertService.alert({ ...alert, severity: 'error' });
}
throw response;
+55 -4
View File
@@ -354,11 +354,12 @@
<h3>Live AML Alerts (Awaiting Triage)</h3>
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
</div>
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
<!-- Whitelisted is deliberately absent: cleared alerts are not STR business and have
their own register below. -->
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter">
<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>
@@ -416,7 +417,7 @@
</tr>
} @empty {
<tr>
<td colspan="10" class="fiu-empty">No suspicious behavioral alerts.</td>
<td colspan="10" class="fiu-empty">No suspicious behavioral alerts awaiting triage.</td>
</tr>
}
</tbody>
@@ -424,12 +425,62 @@
</div>
<p class="fiu-tbl-legend">
<span class="report-pill report-pending">Pending</span> awaiting Whitelist/Escalate decision &middot;
<span class="report-pill report-cleared">Cleared</span> whitelisted — no report filed &middot;
<span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case
</p>
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0" />
<!-- Cleared register: whitelisted alerts, kept out of the STR queue but retained with the
evidence recorded at review time. -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>Cleared Alerts (Whitelisted)</h3>
<p>Reviewed and closed as false positives — no STR filed. Retained with the evidence relied on.</p>
</div>
</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>Cleared By</th>
<th>Evidence</th>
</tr>
</thead>
<tbody>
@for (alert of amlWhitelistedAlerts; 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>{{ alert.reviewedByUsername || '—' }}</td>
<td>
<span class="muted">{{ alert.reviewNotes || '—' }}</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="fiu-empty">No alerts have been cleared.</td>
</tr>
}
</tbody>
</table>
</div>
<p class="fiu-tbl-legend">
<span class="report-pill report-cleared">Cleared</span> whitelisted — no report filed, evidence retained for
audit
</p>
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0" />
<!-- LIVE: STR cases with full Principal Officer actions -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
+47 -6
View File
@@ -539,10 +539,14 @@ export class FiuComponent implements OnInit {
: 'Suspicious — STR filed with FIU-IND and account escalated.';
}
/**
* Always fetches every status. The triage table and the cleared register are two views of the
* same set, so filtering server-side would empty one whenever the other was narrowed — and the
* CTR tab reads this list too, which a status filter chosen on the STR tab should not touch.
* amlAlertStatusFilter is applied client-side in {@link amlSuspiciousAlerts} instead.
*/
loadAmlAlerts(): void {
this.amlService
.getAlerts(this.amlAlertStatusFilter || undefined)
.subscribe((data) => (this.amlAlerts = data || []));
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
}
loadAmlCtrReports(): void {
@@ -558,8 +562,25 @@ export class FiuComponent implements OnInit {
return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`);
}
/**
* Clearing or escalating an alert has to carry a reason: review_notes is the only audit record of
* why a flagged transaction was disposed of, and an empty string leaves no trace of the decision.
*/
private reviewNotesOf(alert: any): string | null {
const notes = (this.amlReviewNotes[alert.id] || '').trim();
if (!notes) {
this.toast(`Enter review notes before disposing of alert #${alert.id}`);
return null;
}
return notes;
}
whitelistAlert(alert: any): void {
this.amlService.reviewAlert(alert.id, 'WHITELIST', this.amlReviewNotes[alert.id] || '').subscribe({
const notes = this.reviewNotesOf(alert);
if (!notes) {
return;
}
this.amlService.reviewAlert(alert.id, 'WHITELIST', notes).subscribe({
next: () => {
this.toast(`Alert #${alert.id} whitelisted — no STR raised`);
this.loadAmlAlerts();
@@ -569,7 +590,11 @@ export class FiuComponent implements OnInit {
}
escalateAlert(alert: any): void {
this.amlService.reviewAlert(alert.id, 'ESCALATE', this.amlReviewNotes[alert.id] || '').subscribe({
const notes = this.reviewNotesOf(alert);
if (!notes) {
return;
}
this.amlService.reviewAlert(alert.id, 'ESCALATE', notes).subscribe({
next: () => {
this.toast(`Alert #${alert.id} escalated — now an STR case for the Principal Officer`);
this.loadAmlAlerts();
@@ -780,7 +805,8 @@ export class FiuComponent implements OnInit {
return this.amlAlerts.filter((a) => ctrRuleCodes.includes(a.ruleCode));
}
get amlSuspiciousAlerts(): any[] {
/** Behavioural (non-CTR) alerts. */
private get behaviouralAlerts(): any[] {
const ctrRuleCodes = [
'CTR_THRESHOLD_10L',
'STRUCTURED_CTR_10L_MONTHLY',
@@ -789,6 +815,21 @@ export class FiuComponent implements OnInit {
return this.amlAlerts.filter((a) => !ctrRuleCodes.includes(a.ruleCode));
}
/**
* The STR triage queue: still to decide, or already escalated. Whitelisted alerts are closed
* with no report, so they are not STR business — they live in {@link amlWhitelistedAlerts}.
*/
get amlSuspiciousAlerts(): any[] {
return this.behaviouralAlerts.filter(
(a) => a.status !== 'WHITELISTED' && (!this.amlAlertStatusFilter || a.status === this.amlAlertStatusFilter)
);
}
/** Cleared register: alerts closed as false positives, kept for audit with their evidence. */
get amlWhitelistedAlerts(): any[] {
return this.behaviouralAlerts.filter((a) => a.status === 'WHITELISTED');
}
// -------------------- Head Office vs Branch scope --------------------
/** Maps a FINnet-style internal branch code to the app-wide canonical branch code. */
private canonicalBranchOf(branchCode: string): string | undefined {
@@ -40,6 +40,9 @@
<button class="alm-tab" [class.active]="activeTab === 'monthly'" (click)="setTab('monthly')">
<fa-icon icon="calendar-alt"></fa-icon>&nbsp; Monthly Snapshot
</button>
<button class="alm-tab" [class.active]="activeTab === 'avgbal'" (click)="setTab('avgbal')">
<fa-icon icon="table"></fa-icon>&nbsp; Avg. Liabilities &amp; Assets
</button>
<button class="alm-tab" [class.active]="activeTab === 'returns'" (click)="setTab('returns')">
<fa-icon icon="file-invoice"></fa-icon>&nbsp; Form ALM-1
</button>
@@ -344,6 +347,87 @@
</div>
</ng-container>
<!-- ════════════ AVERAGE LIABILITIES AND ASSETS ════════════ -->
<ng-container *ngIf="activeTab === 'avgbal'">
<div class="alm-section">
<div class="alm-sec-h">
<h3 class="alm-sec-t">Average Liabilities and Assets Month-wise during the year</h3>
<button mat-stroked-button class="alm-btn-xls" (click)="exportAvgBalancesXls()">
<fa-icon icon="file-excel"></fa-icon>&nbsp; Export Excel
</button>
</div>
<!-- Printed-statement banner: institution and office come off the session, not the report. -->
<div class="alm-stmt">
<div class="alm-stmt-bank">{{ statementHeading }}</div>
<div class="alm-stmt-title">Average Liabilities and Assets Month-wise during the year</div>
<div class="alm-tbl-wrap">
<table class="alm-tbl alm-stmt-tbl">
<thead>
<tr>
<th rowspan="2">Month</th>
<th rowspan="2" class="ta-r">Capital</th>
<th rowspan="2" class="ta-r">Reserve/Prov<br />Exclude OIR</th>
<th rowspan="2" class="ta-r">Deposits</th>
<th rowspan="2" class="ta-r">Borrowings</th>
<th rowspan="2" class="ta-r">Other Liabilities</th>
<th rowspan="2" class="ta-r">Cash on Hand</th>
<th colspan="2" class="ta-c">Bank Balances</th>
<th rowspan="2" class="ta-r">Investments<br />including</th>
<th rowspan="2" class="ta-r">Advances</th>
<th rowspan="2" class="ta-r">Other Assets<br />Exclude IRA</th>
</tr>
<tr>
<th class="ta-r">Current</th>
<th class="ta-r">FDs</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let r of avgBalanceRows">
<td class="mono">
<strong>{{ r.label }}</strong
><br /><span class="alm-stmt-sub">{{ r.sub }}</span>
</td>
<td class="ta-r">{{ r.capital | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.reserveProv | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.deposits | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.borrowings | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.otherLiabilities | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.cashOnHand | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.bankCurrent | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.bankFds | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.investments | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.advances | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.otherAssets | number: '1.2-2' }}</td>
</tr>
<tr class="row-total" *ngFor="let r of [avgBalanceTotal, avgBalanceAverage]">
<td>{{ r.label }}</td>
<td class="ta-r">{{ r.capital | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.reserveProv | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.deposits | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.borrowings | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.otherLiabilities | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.cashOnHand | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.bankCurrent | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.bankFds | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.investments | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.advances | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.otherAssets | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="alm-stmt-foot">
<div>Print Date : {{ statementPrintedAt }}</div>
<div>Report Ref No : {{ bank.reportRefNo }}&nbsp;&nbsp;User Code:{{ statementUserCode }}</div>
</div>
</div>
</div>
</ng-container>
<!-- ════════════ RETURNS / FORM ALM-1 ════════════ -->
<ng-container *ngIf="activeTab === 'returns'">
<div class="alm-section">
@@ -597,6 +597,58 @@
}
}
/* AVERAGE LIABILITIES AND ASSETS — laid out as the statement prints */
.alm-stmt {
padding: 16px;
border: 1px solid var(--alm-g200);
border-radius: 10px;
background: #fff;
}
.alm-stmt-bank {
font-size: 13px;
font-weight: 700;
color: var(--alm-g900);
}
.alm-stmt-title {
margin: 2px 0 14px;
font-size: 12px;
color: var(--alm-g500);
}
.alm-stmt-tbl {
th,
td {
padding: 7px 10px;
font-size: 12px;
}
th {
text-transform: none;
letter-spacing: 0;
white-space: normal;
}
td.ta-r {
font-family: 'DM Mono', monospace;
}
}
.alm-stmt-sub {
font-size: 10px;
color: var(--alm-g500);
}
.alm-stmt-foot {
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid var(--alm-g200);
font-size: 11px;
line-height: 1.7;
color: var(--alm-g500);
}
.status-pill {
display: inline-block;
padding: 3px 10px;
@@ -13,6 +13,8 @@ import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { BranchScopeService } from 'app/core/branch-scope.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
interface MaturityBucket {
code: string;
@@ -31,6 +33,37 @@ interface MonthSnapshot {
cashOnHand: number;
investments: number;
otherAssets: number;
// ── Columns the month-wise average statement adds to the snapshot above ──
// The statement is a full balance sheet averaged over each month, so it needs the own-funds
// and deposit sides the liquidity tabs do not use, and it splits bank balances from cash.
/** Paid-up share capital. */
capital: number;
/** Total deposits — the FD component above is part of this figure. */
deposits: number;
otherLiabilities: number;
/** Balances with other banks, current accounts. */
bankCurrent: number;
/** Balances with other banks, held as fixed deposits. */
bankFds: number;
advances: number;
}
/** One row of the month-wise average statement — a month, or the Total / Average line. */
interface AvgBalanceRow {
label: string;
/** Sub-label under the month, e.g. the year, as the printed statement prints it. */
sub: string;
capital: number;
reserveProv: number;
deposits: number;
borrowings: number;
otherLiabilities: number;
cashOnHand: number;
bankCurrent: number;
bankFds: number;
investments: number;
advances: number;
otherAssets: number;
}
@Component({
@@ -50,8 +83,10 @@ interface MonthSnapshot {
})
export class AlmReportDialogComponent {
private dialogRef = inject(MatDialogRef<AlmReportDialogComponent>);
private branchScope = inject(BranchScopeService);
private auth = inject(AuthenticationService);
activeTab: 'dashboard' | 'ladder' | 'monthly' | 'returns' = 'dashboard';
activeTab: 'dashboard' | 'ladder' | 'monthly' | 'avgbal' | 'returns' = 'dashboard';
bank = {
name: 'CBSNexora Bank Ltd',
@@ -95,7 +130,13 @@ export class AlmReportDialogComponent {
fds: 462800,
cashOnHand: 8420,
investments: 184600,
otherAssets: 12480
otherAssets: 12480,
capital: 24200,
deposits: 602800,
otherLiabilities: 28400,
bankCurrent: 18900,
bankFds: 46200,
advances: 372400
},
{
month: 'MAY-2025',
@@ -104,7 +145,13 @@ export class AlmReportDialogComponent {
fds: 468200,
cashOnHand: 8680,
investments: 186400,
otherAssets: 12620
otherAssets: 12620,
capital: 24280,
deposits: 608600,
otherLiabilities: 28560,
bankCurrent: 19240,
bankFds: 46600,
advances: 376800
},
{
month: 'JUN-2025',
@@ -113,7 +160,13 @@ export class AlmReportDialogComponent {
fds: 472400,
cashOnHand: 9120,
investments: 188200,
otherAssets: 12840
otherAssets: 12840,
capital: 24360,
deposits: 613400,
otherLiabilities: 28720,
bankCurrent: 19580,
bankFds: 47000,
advances: 381200
},
{
month: 'JUL-2025',
@@ -122,7 +175,13 @@ export class AlmReportDialogComponent {
fds: 478600,
cashOnHand: 9280,
investments: 190400,
otherAssets: 13020
otherAssets: 13020,
capital: 24460,
deposits: 619800,
otherLiabilities: 28880,
bankCurrent: 19860,
bankFds: 47400,
advances: 385600
},
{
month: 'AUG-2025',
@@ -131,7 +190,13 @@ export class AlmReportDialogComponent {
fds: 482400,
cashOnHand: 9420,
investments: 192800,
otherAssets: 13280
otherAssets: 13280,
capital: 24540,
deposits: 624200,
otherLiabilities: 29040,
bankCurrent: 20180,
bankFds: 47800,
advances: 390200
},
{
month: 'SEP-2025',
@@ -140,7 +205,13 @@ export class AlmReportDialogComponent {
fds: 488200,
cashOnHand: 9640,
investments: 195200,
otherAssets: 13420
otherAssets: 13420,
capital: 24620,
deposits: 630400,
otherLiabilities: 29200,
bankCurrent: 20460,
bankFds: 48200,
advances: 394800
},
{
month: 'OCT-2025',
@@ -149,7 +220,13 @@ export class AlmReportDialogComponent {
fds: 492600,
cashOnHand: 9820,
investments: 197600,
otherAssets: 13620
otherAssets: 13620,
capital: 24720,
deposits: 635200,
otherLiabilities: 29380,
bankCurrent: 20740,
bankFds: 48600,
advances: 399400
},
{
month: 'NOV-2025',
@@ -158,7 +235,13 @@ export class AlmReportDialogComponent {
fds: 498400,
cashOnHand: 9980,
investments: 200200,
otherAssets: 13820
otherAssets: 13820,
capital: 24800,
deposits: 641000,
otherLiabilities: 29540,
bankCurrent: 21020,
bankFds: 49000,
advances: 404200
},
{
month: 'DEC-2025',
@@ -167,7 +250,13 @@ export class AlmReportDialogComponent {
fds: 504200,
cashOnHand: 10240,
investments: 202800,
otherAssets: 14040
otherAssets: 14040,
capital: 24900,
deposits: 646800,
otherLiabilities: 29720,
bankCurrent: 21340,
bankFds: 49400,
advances: 409000
},
{
month: 'JAN-2026',
@@ -176,7 +265,13 @@ export class AlmReportDialogComponent {
fds: 508800,
cashOnHand: 10420,
investments: 205200,
otherAssets: 14240
otherAssets: 14240,
capital: 24980,
deposits: 651600,
otherLiabilities: 29880,
bankCurrent: 21620,
bankFds: 49800,
advances: 413800
},
{
month: 'FEB-2026',
@@ -185,7 +280,13 @@ export class AlmReportDialogComponent {
fds: 514600,
cashOnHand: 10620,
investments: 207800,
otherAssets: 14460
otherAssets: 14460,
capital: 25060,
deposits: 657400,
otherLiabilities: 30040,
bankCurrent: 21900,
bankFds: 50200,
advances: 418600
},
{
month: 'MAR-2026',
@@ -194,7 +295,13 @@ export class AlmReportDialogComponent {
fds: 520400,
cashOnHand: 10820,
investments: 210400,
otherAssets: 14680
otherAssets: 14680,
capital: 25160,
deposits: 663200,
otherLiabilities: 30220,
bankCurrent: 22240,
bankFds: 50600,
advances: 423600
}
];
@@ -256,6 +363,100 @@ export class AlmReportDialogComponent {
return 'balanced';
}
// ── Average Liabilities and Assets, month-wise ────────────────────────────
//
// The printed statement carries the institution and office it was taken for, and who took it.
// Those come off the session rather than being typed into the report, so a branch login prints
// its own office and its own user code instead of whatever the last person hard-coded.
/** "<BANK> , <OFFICE>" — the banner line the printed statement opens with. */
get statementHeading(): string {
return `${this.bank.name.toUpperCase()} , ${this.branchScope.officeName.toUpperCase()}`;
}
/** Who ran the report — the login's username, which is what the printed footer calls a code. */
get statementUserCode(): string {
return (this.auth.getCredentials()?.username || this.bank.userCode).toUpperCase();
}
/** Stamped when the tab is opened, so the footer records when the figures were taken off. */
readonly statementPrintedAt = this.formatPrintDate(new Date());
/**
* Month rows, then the Total and Average lines the statement closes with.
*
* Average is the mean of the months shown, not Total ÷ 12 — a statement printed part-way
* through the year would otherwise divide by months that have not happened yet.
*/
get avgBalanceRows(): AvgBalanceRow[] {
return this.monthly.map((m) => {
const [
name,
year
] = m.month.split('-');
return {
label: name,
sub: year,
capital: m.capital,
reserveProv: m.reserveProv,
deposits: m.deposits,
borrowings: m.borrowings,
otherLiabilities: m.otherLiabilities,
cashOnHand: m.cashOnHand,
bankCurrent: m.bankCurrent,
bankFds: m.bankFds,
investments: m.investments,
advances: m.advances,
otherAssets: m.otherAssets
};
});
}
private readonly avgBalanceColumns: (keyof AvgBalanceRow)[] = [
'capital',
'reserveProv',
'deposits',
'borrowings',
'otherLiabilities',
'cashOnHand',
'bankCurrent',
'bankFds',
'investments',
'advances',
'otherAssets'
];
get avgBalanceTotal(): AvgBalanceRow {
const rows = this.avgBalanceRows;
const out = { label: 'Total', sub: '' } as AvgBalanceRow;
for (const key of this.avgBalanceColumns) {
out[key] = rows.reduce((s, r) => s + (r[key] as number), 0) as never;
}
return out;
}
get avgBalanceAverage(): AvgBalanceRow {
const rows = this.avgBalanceRows;
const total = this.avgBalanceTotal;
const out = { label: 'Average', sub: '' } as AvgBalanceRow;
for (const key of this.avgBalanceColumns) {
out[key] = (rows.length ? +((total[key] as number) / rows.length).toFixed(2) : 0) as never;
}
return out;
}
/** dd/MM/yyyy hh:mm:ssAM — the format the printed statement stamps its footer with. */
private formatPrintDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
const hours24 = d.getHours();
const meridiem = hours24 < 12 ? 'AM' : 'PM';
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
return (
`${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ` +
`${pad(hours12)}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${meridiem}`
);
}
/** Monthly totals helpers */
monthTotalLiab(m: MonthSnapshot): number {
return m.reserveProv + m.borrowings + m.fds;
@@ -407,6 +608,49 @@ export class AlmReportDialogComponent {
this.saveTsv(rows, `ALM-Monthly-${this.dateTag()}.xls`);
}
/** Average Liabilities and Assets — the printed statement, column for column. */
exportAvgBalancesXls(): void {
const rows: string[][] = [];
rows.push([this.statementHeading]);
rows.push(['Average Liabilities and Assets Month-wise during the year']);
rows.push([]);
rows.push([
'Month',
'Capital',
'Reserve/Prov Exclude OIR',
'Deposits',
'Borrowings',
'Other Liabilities',
'Cash on Hand',
'Bank Balances Current',
'Bank Balances FDs',
'Investments including',
'Advances',
'Other Assets Exclude IRA'
]);
const line = (r: AvgBalanceRow): string[] => [
`${r.label} ${r.sub}`.trim(),
r.capital.toFixed(2),
r.reserveProv.toFixed(2),
r.deposits.toFixed(2),
r.borrowings.toFixed(2),
r.otherLiabilities.toFixed(2),
r.cashOnHand.toFixed(2),
r.bankCurrent.toFixed(2),
r.bankFds.toFixed(2),
r.investments.toFixed(2),
r.advances.toFixed(2),
r.otherAssets.toFixed(2)
];
this.avgBalanceRows.forEach((r) => rows.push(line(r)));
rows.push(line(this.avgBalanceTotal));
rows.push(line(this.avgBalanceAverage));
rows.push([]);
rows.push([`Print Date : ${this.statementPrintedAt}`]);
rows.push([`Report Ref No : ${this.bank.reportRefNo} User Code:${this.statementUserCode}`]);
this.saveTsv(rows, `ALM-AverageLiabilitiesAssets-${this.dateTag()}.xls`);
}
/** Maturity ladder CSV */
exportLadderCsv(): void {
const headers = [
@@ -431,7 +675,7 @@ export class AlmReportDialogComponent {
this.saveCsv(rows, `ALM-Ladder-${this.dateTag()}.csv`);
}
setTab(tab: 'dashboard' | 'ladder' | 'monthly' | 'returns'): void {
setTab(tab: 'dashboard' | 'ladder' | 'monthly' | 'avgbal' | 'returns'): void {
this.activeTab = tab;
}
close(): void {
@@ -7,7 +7,16 @@
-->
<h1 mat-dialog-title>{{ 'labels.heading.Session Timeout' | translate }}</h1>
<mat-dialog-content>{{ 'labels.text.Session timed out after a period of inactivity' | translate }}.</mat-dialog-content>
<mat-dialog-actions>
<button mat-raised-button color="primary" mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
<mat-dialog-content>
<div style="display: flex; flex-direction: column; gap: 8px; padding-top: 8px">
<p>{{ 'labels.text.Session timed out after a period of inactivity' | translate }}.</p>
<p>
For your security, you will be logged out in
<strong style="color: #f44336; font-size: 1.1em">{{ countdown }}</strong> seconds.
</p>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end" style="gap: 8px">
<button mat-button color="warn" (click)="logoutNow()">Logout</button>
<button mat-raised-button color="primary" (click)="keepSessionActive()">Stay Logged In</button>
</mat-dialog-actions>
@@ -6,13 +6,14 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
import {
MatDialogRef,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
MatDialogClose,
MAT_DIALOG_DATA
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -30,6 +31,43 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatDialogClose
]
})
export class SessionTimeoutDialogComponent {
export class SessionTimeoutDialogComponent implements OnInit, OnDestroy {
dialogRef = inject<MatDialogRef<SessionTimeoutDialogComponent>>(MatDialogRef);
data = inject<{ countdownSeconds?: number }>(MAT_DIALOG_DATA, { optional: true });
countdown = this.data?.countdownSeconds ?? 60;
private intervalId: any;
ngOnInit() {
this.startCountdown();
}
ngOnDestroy() {
this.clearTimer();
}
private startCountdown() {
this.intervalId = setInterval(() => {
this.countdown--;
if (this.countdown <= 0) {
this.clearTimer();
this.dialogRef.close('logout');
}
}, 1000);
}
private clearTimer() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = undefined;
}
}
keepSessionActive() {
this.dialogRef.close('keep-alive');
}
logoutNow() {
this.dialogRef.close('logout');
}
}
@@ -190,66 +190,47 @@
<div class="km-card">
<div class="km-ch">
<div class="km-ci km-ci-a"><fa-icon icon="calendar-alt"></fa-icon></div>
<div class="km-ct">3-Notice Dispatch Timeline</div>
<span class="km-ch-hint">Expiry: 30 Apr 2027 · Window: 31 Mar – 30 Apr</span>
<div class="km-ct">Escalation Ladder — one channel at a time</div>
<span class="km-ch-hint">
{{ reKycCompletedCount }} of {{ reKycSteps.length }} dispatched · Expiry: 30 Apr 2027 · Window: 31 Mar –
30 Apr
</span>
</div>
<div class="km-cb">
<div class="km-ntl">
<div class="km-nt">
<div class="km-nd-col">
<div class="km-nd km-nd1">1</div>
<div class="km-nl"></div>
</div>
<div>
<div class="km-nh">Notice 1 — Alert window opens</div>
<div class="km-ns">31 March 2027 · Auto-scheduled · <span class="km-ok">✓ Delivered</span></div>
<div class="km-ntags">
<span class="km-cp km-cp-sms">SMS</span>
<span class="km-cp km-cp-mail">Email</span>
<!-- One channel per rung: the next is released only when the current one is dispatched. -->
@for (s of reKycSteps; track s.step; let i = $index) {
<div class="km-nt" [class.km-nt-locked]="s.status === 'LOCKED'">
<div class="km-nd-col">
<div class="km-nd" [ngClass]="'km-nd' + s.notice">{{ s.step }}</div>
<div class="km-nl"></div>
</div>
<div>
@if (isFirstOfNotice(i)) {
<div class="km-nnotice">Notice {{ s.notice }}</div>
}
<div class="km-nh">Step {{ s.step }} — {{ s.channel }}</div>
<div class="km-ns">
{{ s.date }} · {{ s.offset }} ·
<span [ngClass]="reKycStatusClass(s.status)">{{ reKycStatusLabel(s.status) }}</span>
</div>
<div class="km-ntags">
<span class="km-cp" [ngClass]="s.chip">{{ s.channel }}</span>
<span class="km-nchdetail">{{ s.detail }}</span>
</div>
@if (s.status === 'PENDING') {
<div class="km-row-actions">
<button class="km-btn km-btn-t" (click)="dispatchReKycStep(s.step)">
Dispatch {{ s.channel }}
</button>
<button class="km-btn km-btn-ot" (click)="dispatchReKycStep(s.step, true)">
Mark failed — escalate
</button>
</div>
}
</div>
</div>
</div>
<div class="km-nt">
<div class="km-nd-col">
<div class="km-nd km-nd2">2</div>
<div class="km-nl"></div>
</div>
<div>
<div class="km-nh">Notice 2 — Follow-up</div>
<div class="km-ns">10 April 2027 · +10 days · <span class="km-pending">⏳ Pending</span></div>
<div class="km-ntags">
<span class="km-cp km-cp-sms">SMS</span>
<span class="km-cp km-cp-mail">Email</span>
<span class="km-cp km-cp-ivr">IVR Call</span>
<span class="km-cp km-cp-post">Reg. Post</span>
</div>
</div>
</div>
<div class="km-nt">
<div class="km-nd-col">
<div class="km-nd km-nd3">3</div>
<div class="km-nl"></div>
</div>
<div>
<div class="km-nh">Notice 3 — Final notice</div>
<div class="km-ns">20 April 2027 · +20 days · <span class="km-fail">✗ Not dispatched</span></div>
<div class="km-ntags">
<span class="km-cp km-cp-sms">SMS</span>
<span class="km-cp km-cp-mail">Email</span>
<span class="km-cp km-cp-ivr">IVR</span>
<span class="km-cp km-cp-post">Reg. Post</span>
<span class="km-cp km-cp-vis">Home Visit</span>
</div>
<div class="km-row-actions">
<button class="km-btn km-btn-t" (click)="toast('Notice 3 scheduled for 20 Apr 2027')">
Schedule Notice 3
</button>
<button class="km-btn km-btn-ot" (click)="toast('Video KYC link added to Notice 3')">
+ Video KYC Link
</button>
</div>
</div>
</div>
}
<div class="km-nt">
<div class="km-nd-col">
<div class="km-nd km-ndf">!</div>
@@ -295,39 +276,26 @@
<div class="km-card">
<div class="km-ch">
<div class="km-ci km-ci-b"><fa-icon icon="broadcast-tower"></fa-icon></div>
<div class="km-ct">Channels — Notice 3</div>
<div class="km-ct">Channel Sequence</div>
</div>
<!-- Mirrors the ladder: exactly one channel is live, so this is a progress read-out rather
than a set of switches that could all be turned on at once. -->
<div class="km-cb km-channels">
<div class="km-channel km-channel-on" (click)="toast('SMS toggled')">
<div class="km-channel-i">📱</div>
<div class="km-channel-n">SMS</div>
<div class="km-channel-d">Registered mobile</div>
</div>
<div class="km-channel km-channel-on" (click)="toast('Email toggled')">
<div class="km-channel-i">📧</div>
<div class="km-channel-n">Email</div>
<div class="km-channel-d">Registered email</div>
</div>
<div class="km-channel km-channel-on" (click)="toast('IVR toggled')">
<div class="km-channel-i">📞</div>
<div class="km-channel-n">IVR Call</div>
<div class="km-channel-d">Outbound IVR</div>
</div>
<div class="km-channel km-channel-on" (click)="toast('Reg Post toggled')">
<div class="km-channel-i">📮</div>
<div class="km-channel-n">Reg. Post</div>
<div class="km-channel-d">India Post</div>
</div>
<div class="km-channel" (click)="toast('Branch visit toggled')">
<div class="km-channel-i">🏦</div>
<div class="km-channel-n">Home Visit</div>
<div class="km-channel-d">Manual</div>
</div>
<div class="km-channel" (click)="toast('Video KYC toggled')">
<div class="km-channel-i">📹</div>
<div class="km-channel-n">Video KYC</div>
<div class="km-channel-d">V-CIP app</div>
</div>
@for (s of reKycSteps; track s.step) {
<div
class="km-channel"
[class.km-channel-on]="s.status === 'DELIVERED'"
[class.km-channel-live]="s.status === 'PENDING'"
[class.km-channel-off]="s.status === 'LOCKED'"
[title]="reKycStatusLabel(s.status)"
>
<div class="km-channel-n">{{ s.step }}. {{ s.channel }}</div>
<div class="km-channel-d">{{ s.detail }}</div>
<div class="km-channel-s" [ngClass]="reKycStatusClass(s.status)">
{{ reKycStatusLabel(s.status) }}
</div>
</div>
}
</div>
</div>
@@ -734,8 +702,9 @@
<div class="km-page-head">
<span class="km-page-title">KYC Exceptions Dashboard</span>
<div class="km-actions">
<button class="km-btn km-btn-g" (click)="toast('Exporting exception report...')">
<fa-icon icon="download"></fa-icon> Export Exceptions
<button class="km-btn km-btn-g" [disabled]="exportingExceptions" (click)="exportExceptions()">
<fa-icon icon="download"></fa-icon>
{{ exportingExceptions ? 'Preparing…' : 'Export Exceptions' }}
</button>
</div>
</div>
@@ -743,6 +743,57 @@
border-color: #c4b5fd;
}
/* Escalation ladder: a rung waiting on an earlier channel is dimmed so the one actionable
dispatch is unmistakable. */
.km-nt-locked {
opacity: 0.55;
}
.km-locked {
color: #94a3b8;
font-weight: 500;
}
.km-nnotice {
display: inline-block;
margin-bottom: 4px;
padding: 1px 8px;
border-radius: 999px;
background: #eef2ff;
color: #3730a3;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.km-nchdetail {
color: #64748b;
font-size: 11px;
}
/* The one live channel is outlined; completed ones stay green, waiting ones recede. */
.km-channel-live {
border-color: var(--km-amber, #f59e0b) !important;
background: #fffbeb !important;
box-shadow: 0 0 0 2px rgb(245 158 11 / 18%);
}
.km-channel-off {
opacity: 0.5;
}
.km-channel-s {
margin-top: 4px;
font-size: 10px;
font-weight: 700;
}
/* Sequence cards are read-only, so they must not invite a click the way the old toggles did. */
.km-channels .km-channel {
cursor: default;
}
.km-cp-post {
background: #fff7ed;
color: #c2410c;
@@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { ClientsService } from '../clients/clients.service';
import { exportWorkbook, ExportSheetSpec } from '../cashier/cashier-export';
interface KmNotice {
no: string;
@@ -997,6 +998,290 @@ export class KycManagementComponent implements OnInit {
this.toastTimer = setTimeout(() => (this.toastVisible = false), 2800);
}
// ── Re-KYC dispatch ladder ─────────────────────────────────────────────────
/**
* Re-KYC outreach is one channel at a time, escalating only when the previous channel has been
* dispatched — not a bundle of channels fired at once. The regulatory 3-notice structure is kept
* as the grouping (notice), while each row is a single dispatch, which is how it actually happens
* at the branch.
*
* status: DELIVERED / FAILED are done, PENDING is the one dispatch that may go out now, LOCKED is
* waiting on an earlier channel.
*/
reKycSteps: {
step: number;
notice: number;
channel: string;
detail: string;
date: string;
offset: string;
status: 'DELIVERED' | 'FAILED' | 'PENDING' | 'LOCKED';
chip: string;
}[] = [
{
step: 1,
notice: 1,
channel: 'SMS',
detail: 'Registered mobile',
date: '31 March 2027',
offset: 'Window opens',
status: 'DELIVERED',
chip: 'km-cp-sms'
},
{
step: 2,
notice: 1,
channel: 'Email',
detail: 'Registered email',
date: '3 April 2027',
offset: '+3 days',
status: 'DELIVERED',
chip: 'km-cp-mail'
},
{
step: 3,
notice: 2,
channel: 'IVR Call',
detail: 'Outbound IVR',
date: '10 April 2027',
offset: '+10 days',
status: 'PENDING',
chip: 'km-cp-ivr'
},
{
step: 4,
notice: 2,
channel: 'Reg. Post',
detail: 'India Post, tracked',
date: '14 April 2027',
offset: '+14 days',
status: 'LOCKED',
chip: 'km-cp-post'
},
{
step: 5,
notice: 3,
channel: 'Home Visit',
detail: 'Field officer, manual',
date: '20 April 2027',
offset: '+20 days',
status: 'LOCKED',
chip: 'km-cp-vis'
},
{
step: 6,
notice: 3,
channel: 'Video KYC',
detail: 'V-CIP link',
date: '25 April 2027',
offset: '+25 days',
status: 'LOCKED',
chip: 'km-cp-vid'
}
];
/** The single dispatch that may go out now — nothing later is actionable until it is done. */
get currentReKycStep(): number | null {
return this.reKycSteps.find((s) => s.status === 'PENDING')?.step ?? null;
}
get reKycCompletedCount(): number {
return this.reKycSteps.filter((s) => s.status === 'DELIVERED' || s.status === 'FAILED').length;
}
/** First row of a notice carries the notice heading; the rest indent under it. */
isFirstOfNotice(index: number): boolean {
return index === 0 || this.reKycSteps[index - 1].notice !== this.reKycSteps[index].notice;
}
reKycStatusLabel(status: string): string {
switch (status) {
case 'DELIVERED':
return '✓ Dispatched';
case 'FAILED':
return '✗ Failed — escalated';
case 'PENDING':
return '⏳ Due now';
default:
return '🔒 Waiting on previous channel';
}
}
reKycStatusClass(status: string): string {
switch (status) {
case 'DELIVERED':
return 'km-ok';
case 'FAILED':
return 'km-fail';
case 'PENDING':
return 'km-pending';
default:
return 'km-locked';
}
}
/**
* Marks the due channel dispatched and releases only the next one, so the ladder can never skip a
* rung. A failure still advances — the point of escalation is that the next channel is tried.
*/
dispatchReKycStep(step: number, failed = false): void {
const index = this.reKycSteps.findIndex((s) => s.step === step);
if (index < 0 || this.reKycSteps[index].status !== 'PENDING') {
return;
}
const current = this.reKycSteps[index];
current.status = failed ? 'FAILED' : 'DELIVERED';
const next = this.reKycSteps[index + 1];
if (next && next.status === 'LOCKED') {
next.status = 'PENDING';
}
this.toast(
failed
? `${current.channel} failed — escalated to ${next ? next.channel : 'freeze workflow'}`
: `${current.channel} dispatched${next ? ` · next: ${next.channel} on ${next.date}` : ''}`
);
}
// ── Exceptions export ──────────────────────────────────────────────────────
/** True while the workbook is being built, so the button cannot be double-fired. */
exportingExceptions = false;
/**
* Exports the Exceptions Register to Excel: one sheet for pending, one for resolved, mirroring
* the columns on screen. Contact attempts are flattened into a single cell per row rather than a
* third sheet — an auditor reading a row wants the outreach trail beside it, not a lookup.
*/
async exportExceptions(): Promise<void> {
if (this.exportingExceptions) {
return;
}
const sheets: ExportSheetSpec[] = [];
if (this.pendingExceptions.length) {
sheets.push(this.exceptionSheet('Pending Exceptions', this.pendingExceptions, false));
}
if (this.resolvedExceptions.length) {
sheets.push(this.exceptionSheet('Resolved Exceptions', this.resolvedExceptions, true));
}
if (!sheets.length) {
this.toast('There are no exceptions to export.');
return;
}
this.exportingExceptions = true;
try {
const stamp = new Date().toISOString().slice(0, 10);
await exportWorkbook(sheets, `KYC-Exceptions-${stamp}`);
this.toast('Exception report downloaded.');
} catch {
// A failed export must say so — the old stub toasted success unconditionally.
this.toast('Could not build the exception report. Please try again.');
} finally {
this.exportingExceptions = false;
}
}
private exceptionSheet(sheetName: string, rows: KmClient[], resolved: boolean): ExportSheetSpec {
const headers = [
'Client',
'Account No',
'Branch',
'Exception Reason',
'Opened On',
'Risk',
'Contact Status',
'Last Contacted',
'Attempts',
'Contact Log',
'Mobile',
'Email',
'PAN',
'PAN Status',
'Aadhaar',
'Aadhaar Status',
...(resolved ? [
'Resolved On',
'Resolution Remarks'
] : [])
];
const body = rows.map((c) => {
const history = c.contactHistory || [];
const last = history.length ? history[history.length - 1] : null;
return [
c.name || '',
c.account || '',
c.contact?.branch || '',
c.exceptionReason || '',
c.exceptionDate || '',
c.riskLabel || '',
history.length ? 'Contacted' : 'Not Contacted',
last?.date || '',
// Plain count, not a formatted number — exportWorkbook applies #,##0.00 to numbers and
// "3.00 attempts" reads as a bug.
String(history.length),
this.contactLogText(history),
c.contact?.mobile || '',
c.contact?.email || '',
c.kyc?.pan || '',
c.kyc?.panStatus || '',
c.kyc?.aadhaar || '',
c.kyc?.aadhaarStatus || '',
...(resolved ? [
c.exceptionResolvedOn || '',
c.exceptionRemarks || ''
] : [])
];
});
return {
sheetName,
title: `KYC Exceptions Register — ${sheetName}`,
subtitle: `${rows.length} exception(s) · exported ${new Date().toLocaleString('en-IN')}`,
headers,
rows: body,
widths: [
26,
18,
16,
34,
14,
12,
14,
16,
10,
60,
16,
26,
14,
14,
16,
16,
...(resolved ? [
14,
40
] : [])
]
};
}
/**
* One entry per attempt, carrying the delivery outcome so a failed notice is never read as sent.
* Separated with " ; " rather than newlines: the shared sheet builder does not set wrapText on
* body cells, so embedded newlines would collapse into one cramped line in Excel.
*/
private contactLogText(history: KmContactLog[]): string {
return history
.map((log) => {
const outcome = log.deliveryStatus ? ` [${log.deliveryStatus}]` : '';
const officer = log.officer ? ` — ${log.officer}` : '';
const notes = log.notes ? `: ${log.notes}` : '';
return `${log.date} (${log.action})${notes}${officer}${outcome}`;
})
.join(' ; ');
}
/**
* Routes a "Take Action" click. Opens the Home Visit dialog if the action is
* a home-visit scheduling action; otherwise falls back to a toast confirmation.
@@ -0,0 +1,126 @@
<!--
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/.
-->
<h2 mat-dialog-title class="rule-dialog-title">
<mat-icon>add_moderator</mat-icon>
Add AML Rule
</h2>
<mat-dialog-content class="rule-dialog-content">
<form [formGroup]="ruleForm" class="rule-form" autocomplete="off">
<div *ngIf="errorMessage" class="rule-form-error">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMessage }}</span>
</div>
<mat-form-field appearance="outline" class="rule-field-full">
<mat-label>Rule Description</mat-label>
<textarea
matInput
rows="2"
formControlName="ruleName"
placeholder="e.g. Cash deposits exceeding the monthly threshold"
></textarea>
<mat-error *ngIf="ruleForm.get('ruleName')?.hasError('required')">Rule description is required</mat-error>
<mat-error *ngIf="ruleForm.get('ruleName')?.hasError('maxlength')">Keep this under 100 characters</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="rule-field-full">
<mat-label>Rule Reason / Criteria</mat-label>
<textarea
matInput
rows="3"
formControlName="reason"
placeholder="Why the rule exists and what it looks for"
></textarea>
<mat-error *ngIf="ruleForm.get('reason')?.hasError('required')">Reason is required</mat-error>
<mat-error *ngIf="ruleForm.get('reason')?.hasError('maxlength')">Keep this under 500 characters</mat-error>
</mat-form-field>
<div class="rule-grid">
<mat-form-field appearance="outline">
<mat-label>Risk Category</mat-label>
<mat-select formControlName="riskCategory">
<mat-option *ngFor="let r of riskCategories" [value]="r.value">{{ r.label }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Applies Over</mat-label>
<mat-select formControlName="txnWindowDays">
<mat-option *ngFor="let w of windowOptions" [value]="w.value">{{ w.label }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Minimum Limit</mat-label>
<span matPrefix class="rule-field-prefix">₹</span>
<input matInput type="number" min="0" formControlName="txnMinLimitAmount" />
<mat-hint>Leave blank for a non-amount rule</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Maximum Limit</mat-label>
<span matPrefix class="rule-field-prefix">₹</span>
<input matInput type="number" min="0" formControlName="txnMaxLimitAmount" />
<mat-hint>Blank means no upper limit</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Rule Type</mat-label>
<mat-select formControlName="txnRuleType">
<mat-option *ngFor="let t of ruleTypes" [value]="t">{{ t }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Applies To</mat-label>
<mat-select formControlName="txnAppliesTo">
<mat-option *ngFor="let a of appliesToOptions" [value]="a">{{ a }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>AML Check Status</mat-label>
<mat-select formControlName="amlCheckStatus">
<mat-option *ngFor="let s of amlCheckStatuses" [value]="s">{{ s }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Banned Entity Match</mat-label>
<mat-select formControlName="bannedEntityMatch">
<mat-option *ngFor="let y of yesNo" [value]="y">{{ y }}</mat-option>
</mat-select>
</mat-form-field>
</div>
<div *ngIf="bandInverted" class="rule-form-error">
<mat-icon>error_outline</mat-icon>
<span>Maximum limit cannot be lower than the minimum — the rule would never match.</span>
</div>
<p class="rule-form-note">
Amount or window details also create the matching live-transaction rule. Leave them blank for a profile-only rule.
</p>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end" class="rule-dialog-actions">
<button mat-stroked-button type="button" [disabled]="submitting" (click)="cancel()">Cancel</button>
<button
mat-flat-button
color="primary"
type="button"
[disabled]="ruleForm.invalid || bandInverted || submitting"
(click)="submit()"
>
<mat-spinner *ngIf="submitting" diameter="18" class="rule-submit-spinner"></mat-spinner>
{{ submitting ? 'Saving…' : 'Add Rule' }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,88 @@
/**
* 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/.
*/
.rule-dialog-title {
display: flex;
align-items: center;
gap: 10px;
color: #1a237e;
font-weight: 600;
}
.rule-dialog-content {
padding-top: 8px;
min-width: 560px;
max-width: 720px;
}
.rule-form {
display: flex;
flex-direction: column;
gap: 4px;
}
.rule-field-full {
width: 100%;
}
.rule-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px 16px;
}
.rule-field-prefix {
color: #64748b;
margin-right: 6px;
}
.rule-form-error {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 12px;
border: 1px solid #fecaca;
border-radius: 6px;
background: #fef2f2;
color: #b91c1c;
font-size: 13px;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.rule-form-note {
margin: 4px 0 0;
font-size: 12.5px;
color: #64748b;
}
.rule-dialog-actions {
padding: 8px 24px 20px;
gap: 8px;
}
.rule-submit-spinner {
display: inline-block;
margin-right: 8px;
vertical-align: middle;
}
@media (width <= 640px) {
.rule-dialog-content {
min-width: unset;
}
.rule-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -0,0 +1,195 @@
/**
* 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, inject } from '@angular/core';
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { CommonModule } from '@angular/common';
/** Custom Services */
import { ClientsService } from '../../../clients/clients.service';
/**
* Authoring form for a new AML catalog rule.
*
* The screen the officer came from renders one flat table, but a rule is stored across two
* tables: the profile rule in m_client_risk_rule and, when a transaction band or observation
* window is given, an evaluable counterpart in m_aml_transaction_rule. That split is the
* backend's concern — this form stays shaped like the table it was opened from.
*/
@Component({
selector: 'mifosx-add-aml-rule-dialog',
templateUrl: './add-aml-rule-dialog.component.html',
styleUrls: ['./add-aml-rule-dialog.component.scss'],
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule
]
})
export class AddAmlRuleDialogComponent {
private formBuilder = inject(FormBuilder);
private clientsService = inject(ClientsService);
private dialogRef = inject(MatDialogRef<AddAmlRuleDialogComponent>);
submitting = false;
/** Server-side rejection, surfaced in the dialog so the officer keeps their input. */
errorMessage = '';
readonly riskCategories = [
{ value: 3, label: 'High Risk' },
{ value: 2, label: 'Medium Risk' },
{ value: 1, label: 'Low Risk' }
];
/** Mirrors the "Applies Over" column: window in days, per-transaction being no window. */
readonly windowOptions = [
{ value: 0, label: 'Per transaction' },
{ value: 1, label: 'Daily' },
{ value: 7, label: 'Weekly' },
{ value: 30, label: 'Monthly' }
];
readonly ruleTypes = [
'THRESHOLD',
'STRUCTURING',
'VELOCITY'
];
readonly appliesToOptions = [
'ALL',
'SAVINGS',
'LOAN',
'TRANSFER'
];
readonly amlCheckStatuses = [
'CLEAR',
'FLAGGED',
'PENDING'
];
readonly yesNo = [
'NO',
'YES'
];
ruleForm = this.formBuilder.group({
ruleName: [
'',
[
Validators.required,
Validators.maxLength(100)
]
],
reason: [
'',
[
Validators.required,
Validators.maxLength(500)
]
],
riskCategory: [
3,
Validators.required
],
txnWindowDays: [0],
txnRuleType: ['THRESHOLD'],
txnAppliesTo: ['ALL'],
txnMinLimitAmount: [
null as
| number
| null,
Validators.min(0)
],
txnMaxLimitAmount: [
null as
| number
| null,
Validators.min(0)
],
amlCheckStatus: ['FLAGGED'],
bannedEntityMatch: ['NO'],
willfulDefaulter: ['NO'],
ecgcSalFlag: ['NO'],
dormantFlag: ['ACTIVE']
});
/** Mirrors the backend band check so the officer is told before the round trip. */
get bandInverted(): boolean {
const min = this.ruleForm.value.txnMinLimitAmount;
const max = this.ruleForm.value.txnMaxLimitAmount;
return min != null && max != null && Number(max) < Number(min);
}
submit(): void {
if (this.ruleForm.invalid || this.bandInverted) {
this.ruleForm.markAllAsTouched();
return;
}
this.submitting = true;
this.errorMessage = '';
this.clientsService.createClientRiskRule(this.buildPayload()).subscribe({
next: (rule) => {
this.submitting = false;
this.dialogRef.close(rule);
},
error: (err) => {
this.submitting = false;
this.errorMessage = this.readError(err);
}
});
}
cancel(): void {
this.dialogRef.close();
}
private buildPayload(): any {
const v = this.ruleForm.value;
const amount = (n: any) => (n === null || n === undefined || n === '' ? null : Number(n));
return {
ruleName: (v.ruleName || '').trim(),
reason: (v.reason || '').trim(),
riskCategory: v.riskCategory,
amlCheckStatus: v.amlCheckStatus,
bannedEntityMatch: v.bannedEntityMatch,
willfulDefaulter: v.willfulDefaulter,
ecgcSalFlag: v.ecgcSalFlag,
dormantFlag: v.dormantFlag,
// A window of 0 means per-transaction, which the backend stores as a null window rather
// than a zero-day one.
txnWindowDays: v.txnWindowDays ? v.txnWindowDays : null,
txnRuleType: v.txnRuleType,
txnAppliesTo: v.txnAppliesTo,
txnMinLimitAmount: amount(v.txnMinLimitAmount),
txnMaxLimitAmount: amount(v.txnMaxLimitAmount)
};
}
/** Fineract returns field-level failures under errors[]; fall back to the envelope message. */
private readError(err: any): string {
const body = err?.error;
const first = body?.errors?.[0];
return (
first?.defaultUserMessage || first?.developerMessage || body?.defaultUserMessage || 'Could not save the rule.'
);
}
}
@@ -0,0 +1,194 @@
<!--
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="flagged-toolbar">
<div class="flagged-live">
<span class="live-dot" [class.paused]="!live"></span>
<span class="live-label">{{ live ? 'Live' : 'Paused' }}</span>
<span *ngIf="lastUpdated" class="live-updated">Updated {{ lastUpdated | date: 'HH:mm:ss' }}</span>
<mat-spinner *ngIf="loading" diameter="16" class="live-spinner"></mat-spinner>
</div>
<div class="flagged-actions">
<button
*ngIf="newAlertCount > 0"
type="button"
class="new-alert-pill"
matTooltip="Clear the new-alert highlight"
(click)="acknowledgeNew()"
>
<mat-icon>notifications_active</mat-icon>
{{ newAlertCount }} new
</button>
<button
mat-icon-button
type="button"
[matTooltip]="live ? 'Pause live updates' : 'Resume live updates'"
(click)="toggleLive()"
>
<mat-icon>{{ live ? 'pause_circle' : 'play_circle' }}</mat-icon>
</button>
<button mat-icon-button type="button" matTooltip="Refresh now" [disabled]="loading" (click)="fetch()">
<mat-icon>refresh</mat-icon>
</button>
</div>
</div>
<div *ngIf="loadFailed" class="flagged-error">
<mat-icon>cloud_off</mat-icon>
<span>Could not reach the alert feed. Retrying automatically.</span>
</div>
<!-- Narrow the feed by where each alert sits in the review workflow -->
<div class="status-filters">
<span class="status-filters-label">Status</span>
<button
*ngFor="let s of statusFilters"
type="button"
class="status-chip"
[class.active]="activeStatus === s.key"
[disabled]="statusCount(s.key) === 0 && activeStatus !== s.key"
(click)="setStatus(s.key)"
>
{{ s.label }}
<span class="status-chip-n">{{ statusCount(s.key) }}</span>
</button>
</div>
<!-- Severity mix of what the table is showing -->
<div class="risk-summary">
<div *ngFor="let tier of riskSummary" class="risk-tile" [ngClass]="tier.cls">
<span class="risk-tile-n">{{ tier.count }}</span>
<span class="risk-tile-l">{{ tier.label }}</span>
</div>
<div *ngIf="unclassifiedCount > 0" class="risk-tile risk-none" matTooltip="Raised by a user, not by a catalog rule">
<span class="risk-tile-n">{{ unclassifiedCount }}</span>
<span class="risk-tile-l">Unclassified</span>
</div>
<div class="risk-tile risk-total">
<span class="risk-tile-n">{{ summaryTotal }}</span>
<span class="risk-tile-l">Total Flagged</span>
</div>
</div>
<table mat-table [dataSource]="alerts" class="flagged-table mat-elevation-z1">
<!-- When the engine raised the alert -->
<ng-container matColumnDef="raisedOn">
<th mat-header-cell *matHeaderCellDef>Raised On</th>
<td mat-cell *matCellDef="let alert" class="raised-cell">
<span class="raised-time">{{
alert.raisedOnDate ? (alert.raisedOnDate | date: 'dd MMM yyyy, HH:mm') : '—'
}}</span>
<span *ngIf="isNew(alert)" class="new-badge">NEW</span>
</td>
</ng-container>
<!-- Which transaction tripped the rule -->
<ng-container matColumnDef="transaction">
<th mat-header-cell *matHeaderCellDef>Transaction</th>
<td mat-cell *matCellDef="let alert">
<span [ngClass]="txnTypeClass(alert.transactionType)">{{ alert.transactionType }}</span>
<span class="txn-id">#{{ alert.transactionId }}</span>
</td>
</ng-container>
<ng-container matColumnDef="client">
<th mat-header-cell *matHeaderCellDef>Client</th>
<td mat-cell *matCellDef="let alert">
<div class="stacked-cell">
<span>{{ alert.clientDisplayName || '—' }}</span>
<span *ngIf="alert.clientId" class="cell-sub">ID {{ alert.clientId }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="rule">
<th mat-header-cell *matHeaderCellDef>Rule Triggered</th>
<td mat-cell *matCellDef="let alert" class="rule-cell">
<div class="stacked-cell">
<span class="rule-code">{{ alert.ruleCode }}</span>
<span class="cell-sub">{{ alert.ruleDescription }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef class="col-right">Amount</th>
<td mat-cell *matCellDef="let alert" class="col-right amount-cell">{{ amount(alert) }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef class="col-center">Status</th>
<td mat-cell *matCellDef="let alert" class="col-center">
<span [ngClass]="statusClass(alert.status)">{{ alert.status }}</span>
</td>
</ng-container>
<!-- Disposition. Hidden once an alert is already whitelisted or escalated. -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="col-center">Action</th>
<td mat-cell *matCellDef="let alert" class="col-center actions-cell">
<ng-container *ngIf="canReview(alert); else reviewed">
<!-- Same gate the FIU-IND register uses; the endpoint enforces REVIEW_AML_ALERT server side,
so without this a user who cannot review still sees buttons that only fail on click. -->
<div class="action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<button
type="button"
class="action-btn action-whitelist"
matTooltip="Clear this transaction with no report"
(click)="review(alert, 'WHITELIST')"
>
<mat-icon>verified</mat-icon>
Whitelist
</button>
<button
type="button"
class="action-btn action-suspicious"
matTooltip="Raise as suspicious — opens an STR case"
(click)="review(alert, 'ESCALATE')"
>
<mat-icon>report</mat-icon>
Suspicious
</button>
</div>
</ng-container>
<ng-template #reviewed>
<div class="reviewed-cell">
<span class="reviewed-by">
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
</span>
<!-- The evidence recorded at review time, so the trail is readable without leaving the feed -->
<mat-icon
*ngIf="alert.reviewNotes"
class="reviewed-evidence-icon"
[matTooltip]="alert.reviewNotes"
matTooltipClass="evidence-tooltip"
>
description
</mat-icon>
<span *ngIf="alert.status === 'ESCALATED'" class="str-routed" matTooltip="An STR case is open for this alert">
→ STR
</span>
</div>
</ng-template>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="alert-row" [class.row-new]="isNew(row)"></tr>
</table>
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons class="flagged-paginator"></mat-paginator>
<div *ngIf="alerts.data.length === 0 && !loading" class="no-alerts-message">
<mat-icon>{{ activeStatus ? 'filter_alt_off' : 'verified_user' }}</mat-icon>
<span *ngIf="!activeStatus"
>No transactions flagged. The feed updates automatically as the engine raises alerts.</span
>
<span *ngIf="activeStatus">No alerts in this status.</span>
</div>
@@ -0,0 +1,539 @@
/**
* 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/.
*/
.flagged-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 4px 0 14px;
}
.flagged-live {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #334155;
}
/* Pulses while the feed is polling; goes flat and grey when paused. */
.live-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #16a34a;
box-shadow: 0 0 0 0 rgb(22 163 74 / 60%);
animation: live-pulse 2s infinite;
&.paused {
background: #94a3b8;
animation: none;
box-shadow: none;
}
}
@keyframes live-pulse {
0% {
box-shadow: 0 0 0 0 rgb(22 163 74 / 60%);
}
70% {
box-shadow: 0 0 0 8px rgb(22 163 74 / 0%);
}
100% {
box-shadow: 0 0 0 0 rgb(22 163 74 / 0%);
}
}
.live-label {
font-weight: 700;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.05em;
color: #16a34a;
.live-dot.paused + & {
color: #64748b;
}
}
.live-updated {
color: #64748b;
font-size: 12px;
}
.live-spinner {
display: inline-block;
}
.flagged-actions {
display: flex;
align-items: center;
gap: 4px;
}
.new-alert-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: 1px solid #fdba74;
border-radius: 999px;
background: #fff7ed;
color: #c2410c;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&:hover {
background: #ffedd5;
}
}
.flagged-error {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 12px;
border: 1px solid #fecaca;
border-radius: 6px;
background: #fef2f2;
color: #b91c1c;
font-size: 13px;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
/* Chips that narrow the feed by review status. */
.status-filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 0 0 14px;
}
.status-filters-label {
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 13px;
border: 1px solid #cbd5e1;
background: #fff;
border-radius: 999px;
font-size: 12.5px;
font-weight: 600;
color: #334155;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
&:hover:not(:disabled) {
border-color: #1a237e;
color: #1a237e;
}
&.active {
background: #1a237e;
border-color: #1a237e;
color: #fff;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.status-chip-n {
font-size: 11px;
font-weight: 700;
padding: 1px 7px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
.status-chip.active & {
background: rgb(255 255 255 / 22%);
color: #fff;
}
}
/* Severity mix of the visible rows. Colours match the Rules Master risk badges so the same
category reads the same across both tabs. */
.risk-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
padding: 0 0 16px;
}
.risk-tile {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-left-width: 4px;
border-radius: 8px;
background: #fff;
}
.risk-tile-n {
font-size: 22px;
font-weight: 700;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.risk-tile-l {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.risk-tile.risk-high {
border-left-color: #dc2626;
background: #fef2f2;
.risk-tile-n {
color: #b91c1c;
}
}
.risk-tile.risk-medium {
border-left-color: #f59e0b;
background: #fffbeb;
.risk-tile-n {
color: #b45309;
}
}
.risk-tile.risk-low {
border-left-color: #16a34a;
background: #f0fdf4;
.risk-tile-n {
color: #15803d;
}
}
.risk-tile.risk-none {
border-left-color: #94a3b8;
background: #f8fafc;
.risk-tile-n {
color: #475569;
}
}
.risk-tile.risk-total {
border-left-color: #1a237e;
background: #f8fafc;
.risk-tile-n {
color: #1a237e;
}
}
.flagged-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.03em;
}
td {
padding: 14px 16px;
font-size: 13px;
color: #1e293b;
border-bottom: 1px solid #f1f5f9;
}
}
.alert-row:hover {
background: #f8fafc;
}
/* A newly arrived alert stays tinted until acknowledged. */
.row-new {
background: #fffbeb;
&:hover {
background: #fef3c7;
}
}
.col-center {
text-align: center;
}
.col-right {
text-align: right;
}
.raised-cell {
white-space: nowrap;
}
.raised-time {
font-variant-numeric: tabular-nums;
}
.new-badge {
display: inline-block;
margin-left: 8px;
padding: 1px 6px;
border-radius: 4px;
background: #f59e0b;
color: #fff;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
}
.txn-type {
display: inline-block;
padding: 3px 9px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
background: #e2e8f0;
color: #475569;
&.type-savings {
background: #dbeafe;
color: #1d4ed8;
}
&.type-loan {
background: #ede9fe;
color: #6d28d9;
}
&.type-transfer {
background: #ccfbf1;
color: #0f766e;
}
}
.txn-id {
margin-left: 8px;
color: #64748b;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
/* Two-line cells stack inside a wrapper div. Making the <td> itself a flex container drops its
display:table-cell, which collapses the row and pushes later columns onto their own line. */
.stacked-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.cell-sub {
color: #64748b;
font-size: 12px;
}
.rule-code {
font-weight: 600;
}
.rule-cell {
max-width: 300px;
}
.amount-cell {
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.alert-status {
display: inline-block;
padding: 4px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
&.status-raised {
background: #fef3c7;
color: #92400e;
}
&.status-under_review {
background: #dbeafe;
color: #1d4ed8;
}
&.status-escalated {
background: #fee2e2;
color: #b91c1c;
}
&.status-whitelisted {
background: #dcfce7;
color: #15803d;
}
}
.actions-cell {
white-space: nowrap;
}
.action-group {
display: inline-flex;
align-items: center;
gap: 2px;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 11px;
margin: 2px;
border-radius: 6px;
border: 1px solid transparent;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
}
}
.action-whitelist {
border-color: #bbf7d0;
background: #f0fdf4;
color: #15803d;
&:hover {
background: #dcfce7;
border-color: #86efac;
}
}
.action-suspicious {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
&:hover {
background: #fee2e2;
border-color: #fca5a5;
}
}
.reviewed-cell {
display: inline-flex;
align-items: center;
gap: 6px;
justify-content: center;
}
.reviewed-by {
color: #94a3b8;
font-size: 12px;
font-style: italic;
}
.reviewed-evidence-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #64748b;
cursor: help;
}
/* Mirrors the FIU-IND STR register badge, so the same state reads the same in both screens. */
.str-routed {
padding: 2px 7px;
border-radius: 4px;
background: #fef2f2;
border: 1px solid #fecaca;
color: #b91c1c;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
::ng-deep .evidence-tooltip {
max-width: 320px;
white-space: pre-wrap;
font-size: 12px;
line-height: 1.5;
}
.no-alerts-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: #64748b;
gap: 12px;
text-align: center;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
}
}
.flagged-paginator {
background: #fff;
border-top: 1px solid #e2e8f0;
}
@@ -0,0 +1,333 @@
/**
* 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, OnDestroy, inject, ViewChild } from '@angular/core';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { CommonModule } from '@angular/common';
import { Subject, timer, of } from 'rxjs';
import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators';
/** Custom Services */
import { AmlService } from '../../../aml/aml.service';
import { AlertService } from '../../../core/alert/alert.service';
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.component';
/**
* Live feed of transactions the AML rule engine has flagged.
*
* The engine writes an m_aml_alert row synchronously as each savings/loan/transfer transaction
* posts (AmlDomainServiceImpl -> AmlRuleEvaluationService), but there is no push channel out of
* the platform — no websocket or SSE endpoint exists. So "real time" here is a short poll of
* GET /aml/alerts. Alerts therefore surface within one poll interval of the transaction, not on
* the same instant it posts.
*/
@Component({
selector: 'mifosx-aml-flagged-transactions',
templateUrl: './aml-flagged-transactions.component.html',
styleUrls: ['./aml-flagged-transactions.component.scss'],
standalone: true,
imports: [
CommonModule,
MatTableModule,
MatPaginatorModule,
MatIconModule,
MatButtonModule,
MatTooltipModule,
MatProgressSpinnerModule,
MatDialogModule,
HasPermissionDirective
]
})
export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
private amlService = inject(AmlService);
private dialog = inject(MatDialog);
private alertService = inject(AlertService);
private destroy$ = new Subject<void>();
/** Short enough to feel live to an officer watching the desk, long enough not to hammer the API. */
private static readonly POLL_INTERVAL_MS = 15000;
alerts = new MatTableDataSource<any>([]);
displayedColumns: string[] = [
'raisedOn',
'transaction',
'client',
'rule',
'amount',
'status',
'actions'
];
/** Every alert as returned, before the status filter narrows the table. */
private allAlerts: any[] = [];
readonly statusFilters: { key: string; label: string }[] = [
{ key: '', label: 'All alerts' },
{ key: 'RAISED', label: 'Raised' },
{ key: 'UNDER_REVIEW', label: 'Under review' },
{ key: 'ESCALATED', label: 'Escalated' },
{ key: 'WHITELISTED', label: 'Whitelisted' }
];
activeStatus = '';
private statusCounts: Record<string, number> = {};
/**
* Severity mix of the flagged transactions currently in the table. riskCategory is the severity
* of the rule that fired, resolved server-side from the originating catalog entry — the same
* field the Rules Master tab shows in its Risk Category column.
*/
riskSummary: { label: string; count: number; cls: string }[] = [];
/** Alerts whose rule has no catalog ancestor (MANUAL_REFERRAL), so no severity to report. */
unclassifiedCount = 0;
live = true;
loading = false;
/** Null until the first successful poll, so the header does not claim a stale time. */
lastUpdated: Date | null = null;
loadFailed = false;
/** Ids already on screen, so a later poll can tell genuinely new alerts from repeats. */
private seenIds = new Set<number>();
/** Alerts that arrived after the officer started watching — highlighted until acknowledged. */
newAlertIds = new Set<number>();
@ViewChild(MatPaginator) paginator!: MatPaginator;
ngOnInit(): void {
this.fetch();
timer(AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS, AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS)
.pipe(
// Skip while paused, and while the browser tab is in the background — a hidden tab
// polling every 15s is pure waste, and the next visible tick catches up anyway.
filter(() => this.live && !document.hidden),
// Always fetch unfiltered: the status chips need counts for every bucket, and filtering
// client-side keeps a chip switch instant instead of a round trip.
switchMap(() => this.amlService.getAlerts().pipe(catchError(() => of(null)))),
takeUntil(this.destroy$)
)
.subscribe((data) => {
if (data) {
this.applyAlerts(data);
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/** Manual refresh, and the initial load. */
fetch(): void {
this.loading = true;
this.amlService
.getAlerts()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (data) => {
this.loading = false;
this.applyAlerts(data || []);
},
error: () => {
this.loading = false;
this.loadFailed = true;
}
});
}
toggleLive(): void {
this.live = !this.live;
if (this.live) {
// Resuming should show current state immediately rather than after a full interval.
this.fetch();
}
}
setStatus(key: string): void {
this.activeStatus = key;
this.applyStatusFilter();
}
statusCount(key: string): number {
return this.statusCounts[key] ?? 0;
}
get newAlertCount(): number {
return this.newAlertIds.size;
}
/** Clears the NEW highlight once the officer has looked at them. */
acknowledgeNew(): void {
this.newAlertIds.clear();
}
isNew(alert: any): boolean {
return this.newAlertIds.has(alert.id);
}
private applyAlerts(data: any[]): void {
this.loadFailed = false;
// A live feed reads newest-first. The API returns ascending when a status filter is applied
// and unordered otherwise, so ordering here is what makes the newest alert visible on page 1.
// Sorting needs the parsed date: raisedOn itself is an array, and new Date([...]) is NaN,
// which would silently make the comparator a no-op.
const list = (data || [])
.map((a) => ({ ...a, raisedOnDate: this.toDate(a.raisedOn) }))
.sort((a, b) => (b.raisedOnDate?.getTime() ?? 0) - (a.raisedOnDate?.getTime() ?? 0));
const firstLoad = this.lastUpdated === null;
if (!firstLoad) {
// Accumulate rather than replace: two quiet polls in a row must not clear a highlight the
// officer has not looked at yet.
for (const alert of list) {
if (!this.seenIds.has(alert.id)) {
this.newAlertIds.add(alert.id);
}
}
}
this.seenIds = new Set(list.map((a) => a.id));
this.allAlerts = list;
this.recomputeStatusCounts();
this.applyStatusFilter();
this.lastUpdated = new Date();
}
private applyStatusFilter(): void {
this.alerts.data = this.activeStatus
? this.allAlerts.filter((a) => a.status === this.activeStatus)
: this.allAlerts;
if (!this.alerts.paginator && this.paginator) {
this.alerts.paginator = this.paginator;
}
this.recomputeRiskSummary();
}
/**
* Counted over the rows the status filter leaves visible, not the whole set — a summary that
* disagreed with the table underneath it would be worse than no summary.
*/
private recomputeRiskSummary(): void {
const pool = this.alerts.data;
const tiers = [
{ key: 3, label: 'High Risk', cls: 'risk-high' },
{ key: 2, label: 'Medium Risk', cls: 'risk-medium' },
{ key: 1, label: 'Low Risk', cls: 'risk-low' }
];
this.riskSummary = tiers.map((t) => ({
label: t.label,
cls: t.cls,
count: pool.filter((a) => Number(a.riskCategory) === t.key).length
}));
// Anything outside 1-3, including null, so the tiles always reconcile to the total.
this.unclassifiedCount = pool.filter(
(a) => ![
1,
2,
3
].includes(Number(a.riskCategory))
).length;
}
get summaryTotal(): number {
return this.alerts.data.length;
}
private recomputeStatusCounts(): void {
this.statusCounts = { '': this.allAlerts.length };
for (const s of this.statusFilters) {
if (s.key) {
this.statusCounts[s.key] = this.allAlerts.filter((a) => a.status === s.key).length;
}
}
}
statusClass(status: string): string {
return 'alert-status status-' + (status || 'raised').toLowerCase();
}
txnTypeClass(type: string): string {
return 'txn-type type-' + (type || 'unknown').toLowerCase();
}
amount(alert: any): string {
const value = Number(alert.amount) || 0;
return '₹' + value.toLocaleString('en-IN');
}
/**
* Fineract's Gson LocalDateTimeAdapter emits [year, month, day, hour, minute, second] rather
* than an ISO string, so binding raisedOn straight into the date pipe renders nothing. Month is
* 1-based in that array and 0-based in Date. The string branch is a fallback in case the
* serializer is ever configured to emit ISO.
*/
private toDate(value: any): Date | null {
if (!value) {
return null;
}
if (Array.isArray(value)) {
const [
y,
M,
d,
H = 0,
m = 0,
s = 0
] = value;
if (y == null || M == null || d == null) {
return null;
}
return new Date(y, M - 1, d, H, m, s);
}
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
/**
* Only an open alert can be disposed of. The backend's whitelist/escalate set state
* unconditionally, so nothing there stops an already-escalated alert being escalated again and
* opening a second STR case — this gate is what prevents it.
*/
canReview(alert: any): boolean {
return alert?.status === 'RAISED' || alert?.status === 'UNDER_REVIEW';
}
review(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
const dialogRef = this.dialog.open(AmlReviewAlertDialogComponent, {
width: '620px',
maxWidth: '95vw',
autoFocus: false,
disableClose: true,
data: { alert, disposition }
});
dialogRef.afterClosed().subscribe((result: 'WHITELIST' | 'ESCALATE' | undefined) => {
if (!result) {
return;
}
this.alertService.alert({
type: result === 'WHITELIST' ? 'Alert Whitelisted' : 'Alert Escalated',
message:
result === 'WHITELIST'
? `${alert.transactionType} #${alert.transactionId} cleared with no report.`
: `${alert.transactionType} #${alert.transactionId} escalated — an STR case is now open.`
});
// The status changed server side, and escalation also re-rates the client, so re-read
// rather than patching the row locally.
this.fetch();
});
}
}
@@ -0,0 +1,135 @@
<!--
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/.
-->
<h2 mat-dialog-title class="review-dialog-title" [class.escalate]="!isWhitelist">
<mat-icon>{{ isWhitelist ? 'verified' : 'report' }}</mat-icon>
{{ title }}
</h2>
<mat-dialog-content class="review-dialog-content">
<!-- Restate exactly which transaction is being disposed of -->
<div class="review-summary">
<div class="review-summary-row">
<span class="review-label">Transaction</span>
<span class="review-value">
{{ data.alert.transactionType }} <span class="review-mono">#{{ data.alert.transactionId }}</span>
</span>
</div>
<div class="review-summary-row">
<span class="review-label">Client</span>
<span class="review-value">
{{ data.alert.clientDisplayName || '—' }}
<span *ngIf="data.alert.clientId" class="review-muted">(ID {{ data.alert.clientId }})</span>
</span>
</div>
<div class="review-summary-row">
<span class="review-label">Amount</span>
<span class="review-value review-mono">{{ amount() }}</span>
</div>
<div class="review-summary-row">
<span class="review-label">Rule</span>
<span class="review-value">
{{ data.alert.ruleCode }}
<span class="review-muted">— {{ data.alert.ruleDescription }}</span>
</span>
</div>
<div class="review-summary-row">
<span class="review-label">Raised On</span>
<span class="review-value">{{ data.alert.raisedOnDate | date: 'dd MMM yyyy, HH:mm:ss' }}</span>
</div>
</div>
<!-- Escalation is not a reversible bookkeeping change; say what it sets in motion -->
<div *ngIf="!isWhitelist" class="review-warning">
<mat-icon>warning</mat-icon>
<span>
This opens an STR case for the Principal Officer and re-rates the client's risk category. The case carries a
7-working-day FIU filing deadline.
</span>
</div>
<div *ngIf="isWhitelist" class="review-note">
<mat-icon>info_outline</mat-icon>
<span>The alert is closed with no report filed. The reason below is retained as the audit trail.</span>
</div>
<div *ngIf="errorMessage" class="review-error">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMessage }}</span>
</div>
<form [formGroup]="reviewForm" autocomplete="off">
<!-- Evidence classification only applies to clearing an alert -->
<ng-container *ngIf="isWhitelist">
<div class="evidence-block" [class.evidence-block-invalid]="evidenceMissing">
<span class="evidence-legend">Evidence relied on — tick all that apply</span>
<div formArrayName="evidence" class="evidence-options">
<mat-checkbox *ngFor="let t of evidenceTypes; let i = index" [formControlName]="i" class="evidence-option">
{{ t }}
</mat-checkbox>
</div>
<span *ngIf="evidenceMissing" class="evidence-error">Tick at least one form of evidence</span>
</div>
<mat-form-field appearance="outline" class="review-notes-field">
<mat-label>Document / Reference No.</mat-label>
<input matInput formControlName="evidenceRef" placeholder="e.g. Invoice INV-4471, Sale deed 12/2026" />
<mat-hint>Optional — identifies the documents held on file</mat-hint>
</mat-form-field>
</ng-container>
<mat-form-field appearance="outline" class="review-notes-field">
<mat-label>{{ notesLabel }}</mat-label>
<textarea
matInput
rows="4"
formControlName="notes"
[placeholder]="
isWhitelist
? 'What the evidence shows and how it accounts for this transaction'
: 'The pattern or behaviour that makes this transaction suspicious'
"
></textarea>
<mat-error *ngIf="reviewForm.get('notes')?.hasError('required')">
Required — this is the audit record for the decision
</mat-error>
<mat-error *ngIf="reviewForm.get('notes')?.hasError('maxlength')">
Keep this under {{ detailMax }} characters
</mat-error>
</mat-form-field>
<!-- No guessing about what is retained: show the exact string that will be stored. -->
<div *ngIf="composedNotes" class="review-stored">
<span class="review-stored-label">Stored on the alert record</span>
<code class="review-stored-value">{{ composedNotes }}</code>
<span class="review-stored-hint">
Saved to <code>m_aml_alert.review_notes</code> against alert #{{ data.alert.id }}, and shown in the NOTES column
of the FIU&#8209;IND STR register.
</span>
</div>
<div *ngIf="composedTooLong" class="review-error">
<mat-icon>error_outline</mat-icon>
<span>Too long to store — untick some evidence, or shorten the detail or reference.</span>
</div>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end" class="review-dialog-actions">
<button mat-stroked-button type="button" [disabled]="submitting" (click)="cancel()">Cancel</button>
<button
mat-flat-button
type="button"
[color]="isWhitelist ? 'primary' : 'warn'"
[disabled]="reviewForm.invalid || composedTooLong || submitting"
(click)="submit()"
>
<mat-spinner *ngIf="submitting" diameter="18" class="review-spinner"></mat-spinner>
{{ submitting ? 'Saving…' : confirmLabel }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,215 @@
/**
* 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/.
*/
.review-dialog-title {
display: flex;
align-items: center;
gap: 10px;
color: #15803d;
font-weight: 600;
&.escalate {
color: #b91c1c;
}
}
.review-dialog-content {
padding-top: 8px;
min-width: 520px;
max-width: 640px;
}
.review-summary {
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
padding: 14px 16px;
margin-bottom: 16px;
}
.review-summary-row {
display: flex;
gap: 12px;
padding: 4px 0;
font-size: 13px;
align-items: baseline;
}
.review-label {
flex: 0 0 96px;
color: #64748b;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.review-value {
flex: 1 1 auto;
color: #1e293b;
font-weight: 500;
}
.review-muted {
color: #64748b;
font-weight: 400;
}
.review-mono {
font-variant-numeric: tabular-nums;
}
.review-warning,
.review-note,
.review-error {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
margin-bottom: 16px;
border-radius: 6px;
font-size: 13px;
line-height: 1.45;
mat-icon {
flex: 0 0 auto;
font-size: 20px;
width: 20px;
height: 20px;
}
}
.review-warning {
border: 1px solid #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.review-note {
border: 1px solid #bbf7d0;
background: #f0fdf4;
color: #15803d;
}
.review-error {
border: 1px solid #fecaca;
background: #fef2f2;
color: #b91c1c;
font-weight: 500;
}
/* Multi-select evidence. Bordered so the group reads as one required field rather than a
loose row of checkboxes. */
.evidence-block {
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 16px;
&.evidence-block-invalid {
border-color: #fca5a5;
background: #fef2f2;
}
}
.evidence-legend {
display: block;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin-bottom: 8px;
}
.evidence-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px 12px;
}
.evidence-option {
font-size: 13px;
}
.evidence-error {
display: block;
margin-top: 8px;
font-size: 12px;
font-weight: 500;
color: #b91c1c;
}
.review-notes-field {
width: 100%;
}
/* Shows the officer the exact string the review will persist. */
.review-stored {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border: 1px dashed #cbd5e1;
border-radius: 6px;
background: #f8fafc;
}
.review-stored-label {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.review-stored-value {
font-size: 12.5px;
color: #1e293b;
word-break: break-word;
line-height: 1.45;
}
.review-stored-hint {
font-size: 11.5px;
color: #64748b;
line-height: 1.45;
code {
background: #e2e8f0;
padding: 1px 4px;
border-radius: 3px;
font-size: 11px;
}
}
.review-dialog-actions {
padding: 8px 24px 20px;
gap: 8px;
}
.review-spinner {
display: inline-block;
margin-right: 8px;
vertical-align: middle;
}
@media (width <= 600px) {
.review-dialog-content {
min-width: unset;
}
.review-summary-row {
flex-direction: column;
gap: 2px;
}
.evidence-options {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -0,0 +1,212 @@
/**
* 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, inject } from '@angular/core';
import {
AbstractControl,
FormArray,
FormBuilder,
ValidationErrors,
Validators,
ReactiveFormsModule
} from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { CommonModule } from '@angular/common';
/** Custom Services */
import { AmlService } from '../../../aml/aml.service';
export interface AmlReviewDialogData {
alert: any;
disposition: 'WHITELIST' | 'ESCALATE';
}
/**
* Disposes of one engine alert: clear it (WHITELIST) or raise it as suspicious (ESCALATE).
*
* The alert being actioned is restated in full here rather than relying on the officer having
* read the right table row — the two outcomes are not symmetric. WHITELIST closes the alert with
* no report; ESCALATE opens an STR case and re-rates the client's risk, which is not something to
* trigger from a stray click on a table. Notes are mandatory because they are the audit trail for
* why a flagged transaction was cleared.
*/
@Component({
selector: 'mifosx-aml-review-alert-dialog',
templateUrl: './aml-review-alert-dialog.component.html',
styleUrls: ['./aml-review-alert-dialog.component.scss'],
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatCheckboxModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule
]
})
export class AmlReviewAlertDialogComponent {
private formBuilder = inject(FormBuilder);
private amlService = inject(AmlService);
private dialogRef = inject(MatDialogRef<AmlReviewAlertDialogComponent>);
readonly data = inject<AmlReviewDialogData>(MAT_DIALOG_DATA);
submitting = false;
errorMessage = '';
/**
* What supports clearing the alert. The platform has no evidence table — m_aml_alert.review_notes
* (VARCHAR(1000)) is the only field the review writes — so the classification is folded into that
* one string rather than invented as a column the API cannot persist.
*/
readonly evidenceTypes = [
'Customer explanation obtained',
'Source of funds documented',
'KYC / identity re-verified',
'Consistent with declared income or turnover',
'Established business or salary pattern',
'Duplicate or system-generated alert',
'Other'
];
/** Leaves headroom for the composed prefix inside the 1000-char column. */
private static readonly DETAIL_MAX = 800;
private static readonly NOTES_COLUMN_MAX = 1000;
/** One checkbox per evidence type; a clearing decision can rest on several at once. */
reviewForm = this.formBuilder.group({
evidence: this.formBuilder.array(this.evidenceTypes.map(() => false)),
evidenceRef: [
'',
Validators.maxLength(100)
],
notes: [
'',
[
Validators.required,
Validators.maxLength(AmlReviewAlertDialogComponent.DETAIL_MAX)
]
]
});
constructor() {
// Evidence classification is meaningless on an escalation — that path records grounds for
// suspicion, not proof of innocence — so it is only required when clearing an alert.
if (this.isWhitelist) {
this.evidenceArray.addValidators(AmlReviewAlertDialogComponent.atLeastOneChecked);
this.evidenceArray.updateValueAndValidity();
}
}
/** FormArray of booleans, so "none ticked" has to be checked across the whole array. */
private static atLeastOneChecked(control: AbstractControl): ValidationErrors | null {
const boxes = control as FormArray;
return boxes.controls.some((c) => c.value === true) ? null : { required: true };
}
get evidenceArray(): FormArray {
return this.reviewForm.get('evidence') as FormArray;
}
get selectedEvidence(): string[] {
return this.evidenceTypes.filter((_, i) => this.evidenceArray.at(i)?.value === true);
}
get evidenceMissing(): boolean {
return this.isWhitelist && this.evidenceArray.invalid && this.evidenceArray.touched;
}
get isWhitelist(): boolean {
return this.data.disposition === 'WHITELIST';
}
get detailMax(): number {
return AmlReviewAlertDialogComponent.DETAIL_MAX;
}
/** Exactly what will be written to the alert record, shown before the officer commits. */
get composedNotes(): string {
const v = this.reviewForm.value;
const detail = (v.notes || '').trim();
if (!this.isWhitelist) {
return detail ? `Grounds: ${detail}` : '';
}
const parts: string[] = [];
const selected = this.selectedEvidence;
if (selected.length) {
parts.push(`Evidence: ${selected.join('; ')}`);
}
if (v.evidenceRef) {
parts.push(`Ref: ${(v.evidenceRef || '').trim()}`);
}
const head = parts.join(' | ');
return head && detail ? `${head} — ${detail}` : head || detail;
}
get composedTooLong(): boolean {
return this.composedNotes.length > AmlReviewAlertDialogComponent.NOTES_COLUMN_MAX;
}
get title(): string {
return this.isWhitelist ? 'Whitelist Transaction' : 'Mark as Suspicious';
}
get confirmLabel(): string {
return this.isWhitelist ? 'Whitelist' : 'Escalate to STR';
}
get notesLabel(): string {
return this.isWhitelist ? 'Evidence detail' : 'What makes this transaction suspicious?';
}
submit(): void {
if (this.reviewForm.invalid || this.composedTooLong) {
this.reviewForm.markAllAsTouched();
return;
}
this.submitting = true;
this.errorMessage = '';
this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes).subscribe({
next: () => {
this.submitting = false;
this.dialogRef.close(this.data.disposition);
},
error: (err) => {
this.submitting = false;
this.errorMessage = this.readError(err);
}
});
}
cancel(): void {
this.dialogRef.close();
}
amount(): string {
const value = Number(this.data.alert?.amount) || 0;
return '₹' + value.toLocaleString('en-IN');
}
private readError(err: any): string {
const body = err?.error;
const first = body?.errors?.[0];
return (
first?.defaultUserMessage || first?.developerMessage || body?.defaultUserMessage || 'Could not record the review.'
);
}
}
@@ -0,0 +1,217 @@
<!--
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="ra-intro">
<mat-icon class="ra-intro-icon">policy</mat-icon>
<span>
Customers the engine has flagged more than once. One alert is a question about a transaction — a pattern is a
question about the customer, which is what gets reviewed and recorded here.
</span>
</div>
<!-- What counts as "regularly" is a policy call, so it is set on screen rather than baked in -->
<div class="ra-controls">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="ra-control">
<mat-label>Flag after</mat-label>
<mat-select [(ngModel)]="minAlerts" (ngModelChange)="load()">
<mat-option *ngFor="let t of thresholdOptions" [value]="t">{{ t }}+ alerts</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="ra-control">
<mat-label>Within</mat-label>
<mat-select [(ngModel)]="windowDays" (ngModelChange)="load()">
<mat-option *ngFor="let w of windowOptions" [value]="w.value">{{ w.label }}</mat-option>
</mat-select>
</mat-form-field>
<mat-checkbox [(ngModel)]="onlyNeedingReview" (ngModelChange)="load()" class="ra-check">
Only awaiting review
</mat-checkbox>
<span class="ra-spacer"></span>
<span *ngIf="needingReviewCount > 0" class="ra-pending-pill">
<mat-icon>pending_actions</mat-icon>
{{ needingReviewCount }} awaiting review
</span>
<mat-spinner *ngIf="loading" diameter="18"></mat-spinner>
<button mat-icon-button type="button" matTooltip="Refresh" [disabled]="loading" (click)="load()">
<mat-icon>refresh</mat-icon>
</button>
</div>
<div *ngIf="loadFailed" class="ra-error">
<mat-icon>cloud_off</mat-icon>
<span>Could not load the review queue.</span>
</div>
<!-- Named on screen, because sample rows must never be mistaken for engine output on an AML queue -->
<div *ngIf="usingDemoData && rows.data.length > 0" class="ra-demo">
<mat-icon>science</mat-icon>
<span>
Showing <strong>sample data</strong> — the AML engine returned no recurring alerts for this threshold and window.
These rows are for demonstration only and are not reportable.
</span>
</div>
<table mat-table [dataSource]="rows" class="ra-table mat-elevation-z1" multiTemplateDataRows>
<ng-container matColumnDef="customer">
<th mat-header-cell *matHeaderCellDef>Customer</th>
<td mat-cell *matCellDef="let row">
<div class="ra-stacked">
<span class="ra-name">{{ row.clientDisplayName }}</span>
<span class="ra-sub">
ID {{ row.clientId }}<span *ngIf="row.accountNo"> · {{ row.accountNo }}</span> · KYC risk
<span [ngClass]="riskClass(row.clientRiskCategory)">{{ riskLabel(row.clientRiskCategory) }}</span>
</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="alertCount">
<th mat-header-cell *matHeaderCellDef class="col-center">Alerts</th>
<td mat-cell *matCellDef="let row" class="col-center">
<span class="ra-count">{{ row.alertCount }}</span>
</td>
</ng-container>
<ng-container matColumnDef="severity">
<th mat-header-cell *matHeaderCellDef class="col-center">Severity</th>
<td mat-cell *matCellDef="let row" class="col-center">
<div class="ra-stacked ra-stacked-center">
<span class="ra-high" [class.ra-high-zero]="row.highRiskCount === 0">{{ row.highRiskCount }} High</span>
<span class="ra-sub">{{ row.openCount }} open · {{ row.escalatedCount }} escalated</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="rules">
<th mat-header-cell *matHeaderCellDef>Rules Tripped</th>
<td mat-cell *matCellDef="let row" class="ra-rules-cell">
<div class="ra-stacked">
<span class="ra-name">{{ row.distinctRules }} distinct</span>
<span class="ra-sub" [matTooltip]="row.ruleCodes?.join(', ')">{{ row.ruleCodes?.join(', ') }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="totalAmount">
<th mat-header-cell *matHeaderCellDef class="col-right">Total Value</th>
<td mat-cell *matCellDef="let row" class="col-right ra-amount">{{ amount(row.totalAmount) }}</td>
</ng-container>
<ng-container matColumnDef="lastAlert">
<th mat-header-cell *matHeaderCellDef>Activity</th>
<td mat-cell *matCellDef="let row">
<div class="ra-stacked">
<span>{{ row.lastAlertOnDate ? (row.lastAlertOnDate | date: 'dd MMM yyyy') : '—' }}</span>
<span class="ra-sub">
first {{ row.firstAlertOnDate ? (row.firstAlertOnDate | date: 'dd MMM yyyy') : '—' }}
</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="reviewState">
<th mat-header-cell *matHeaderCellDef class="col-center">Review</th>
<td mat-cell *matCellDef="let row" class="col-center">
<span *ngIf="row.needsReview && !row.lastReviewedOn" class="ra-state ra-state-new">Never reviewed</span>
<span
*ngIf="row.needsReview && row.lastReviewedOn"
class="ra-state ra-state-again"
[matTooltip]="
'Reviewed at ' + row.alertCountAtLastReview + ' alert(s); ' + row.alertCount + ' now — new activity since.'
"
>
New activity
</span>
<div *ngIf="!row.needsReview" class="ra-stacked ra-stacked-center">
<span class="ra-state ra-state-done">{{ row.lastReviewOutcome }}</span>
<span class="ra-sub" [matTooltip]="row.lastReviewNotes">
{{ row.lastReviewedByUsername }} · {{ row.lastReviewedOnDate | date: 'dd MMM' }}
</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="col-center">Action</th>
<td mat-cell *matCellDef="let row" class="col-center ra-actions">
<button
type="button"
class="ra-btn ra-btn-detail"
[matTooltip]="isExpanded(row) ? 'Hide transactions' : 'Show the triggering transactions'"
(click)="toggleExpanded(row)"
>
<mat-icon>{{ isExpanded(row) ? 'expand_less' : 'expand_more' }}</mat-icon>
{{ row.alertCount }} txns
</button>
<button
type="button"
class="ra-btn ra-btn-review"
*mifosxHasPermission="'REVIEW_AML_ALERT'"
matTooltip="Record why this customer keeps surfacing"
(click)="review(row)"
>
<mat-icon>fact_check</mat-icon>
Review
</button>
</td>
</ng-container>
<!-- Inline detail: the transactions behind the count, so the reviewer never has to guess -->
<ng-container matColumnDef="detail">
<td mat-cell *matCellDef="let row" [attr.colspan]="displayedColumns.length" class="ra-detail-cell">
<div class="ra-detail" *ngIf="isExpanded(row)">
<table class="ra-detail-tbl">
<thead>
<tr>
<th>Raised On</th>
<th>Transaction</th>
<th>Rule</th>
<th class="col-right">Amount</th>
<th>Status</th>
<th>Review Notes</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let a of row.alerts">
<td>{{ a.raisedOnDate ? (a.raisedOnDate | date: 'dd MMM yyyy, HH:mm') : '—' }}</td>
<td>{{ a.transactionType }} #{{ a.transactionId }}</td>
<td>
{{ a.ruleCode }}
<span class="ra-sub">{{ a.ruleDescription }}</span>
</td>
<td class="col-right ra-amount">{{ amount(a.amount) }}</td>
<td>{{ a.status }}</td>
<td class="ra-sub">{{ a.reviewNotes || '—' }}</td>
</tr>
</tbody>
</table>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
class="ra-row"
[class.ra-row-pending]="row.needsReview"
></tr>
<tr mat-row *matRowDef="let row; columns: ['detail']" class="ra-detail-row"></tr>
</table>
<div *ngIf="rows.data.length === 0 && !loading" class="ra-empty">
<mat-icon>verified_user</mat-icon>
<span>
No customer has reached {{ minAlerts }} alerts in the selected window. Lower the threshold or widen the window to
see borderline cases.
</span>
</div>
@@ -0,0 +1,358 @@
/**
* 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/.
*/
.ra-intro {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 11px 14px;
margin-bottom: 14px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
color: #1e40af;
font-size: 12.5px;
line-height: 1.5;
}
.ra-intro-icon {
flex: 0 0 auto;
font-size: 20px;
width: 20px;
height: 20px;
}
.ra-controls {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding-bottom: 14px;
}
.ra-control {
width: 170px;
}
.ra-check {
font-size: 13px;
}
.ra-spacer {
flex: 1 1 auto;
}
.ra-pending-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: 1px solid #fdba74;
border-radius: 999px;
background: #fff7ed;
color: #c2410c;
font-size: 12.5px;
font-weight: 700;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
.ra-error {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 12px;
border: 1px solid #fecaca;
border-radius: 6px;
background: #fef2f2;
color: #b91c1c;
font-size: 13px;
}
.ra-demo {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
margin-bottom: 12px;
border: 1px solid #fde68a;
border-radius: 6px;
background: #fffbeb;
color: #92400e;
font-size: 12.5px;
line-height: 1.5;
mat-icon {
flex: 0 0 auto;
font-size: 18px;
width: 18px;
height: 18px;
}
}
.ra-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: 12.5px;
padding: 14px 16px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
td {
padding: 12px 16px;
font-size: 13px;
color: #1e293b;
border-bottom: 1px solid #f1f5f9;
}
}
.ra-row:hover {
background: #f8fafc;
}
/* Anything still owed a decision is tinted, so the queue reads at a glance. */
.ra-row-pending {
background: #fffbeb;
&:hover {
background: #fef3c7;
}
}
.col-center {
text-align: center;
}
.col-right {
text-align: right;
}
.ra-stacked {
display: flex;
flex-direction: column;
gap: 2px;
}
.ra-stacked-center {
align-items: center;
}
.ra-name {
font-weight: 600;
}
.ra-sub {
color: #64748b;
font-size: 11.5px;
overflow: hidden;
text-overflow: ellipsis;
}
.ra-rules-cell {
max-width: 260px;
.ra-sub {
display: block;
white-space: nowrap;
}
}
.ra-count {
display: inline-block;
min-width: 30px;
padding: 3px 9px;
border-radius: 999px;
background: #1a237e;
color: #fff;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.ra-high {
font-weight: 700;
color: #b91c1c;
font-size: 12.5px;
&.ra-high-zero {
color: #64748b;
font-weight: 500;
}
}
.ra-amount {
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ra-risk {
font-weight: 700;
&.ra-risk-low {
color: #15803d;
}
&.ra-risk-medium {
color: #b45309;
}
&.ra-risk-high {
color: #b91c1c;
}
&.ra-risk-none {
color: #94a3b8;
}
}
.ra-state {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
white-space: nowrap;
&.ra-state-new {
background: #fee2e2;
color: #b91c1c;
}
&.ra-state-again {
background: #fef3c7;
color: #92400e;
}
&.ra-state-done {
background: #dcfce7;
color: #15803d;
}
}
.ra-actions {
white-space: nowrap;
}
.ra-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 11px;
margin: 2px;
border-radius: 6px;
border: 1px solid transparent;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
}
}
.ra-btn-detail {
border-color: #cbd5e1;
background: #fff;
color: #334155;
&:hover {
border-color: #1a237e;
color: #1a237e;
}
}
.ra-btn-review {
border-color: #c7d2fe;
background: #eef2ff;
color: #3730a3;
&:hover {
background: #e0e7ff;
border-color: #a5b4fc;
}
}
/* Detail row is always rendered by multiTemplateDataRows; the padding collapses when empty so a
collapsed row leaves no gap. */
.ra-detail-cell {
padding: 0 !important;
border-bottom: 0 !important;
}
.ra-detail {
padding: 12px 16px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.ra-detail-tbl {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
overflow: hidden;
th {
background: #f1f5f9;
color: #475569;
font-weight: 700;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 8px 10px;
text-align: left;
}
td {
padding: 8px 10px;
border-top: 1px solid #f1f5f9;
color: #1e293b;
vertical-align: top;
}
}
.ra-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
color: #64748b;
text-align: center;
max-width: 520px;
margin: 0 auto;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
}
}
@@ -0,0 +1,258 @@
/**
* 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 } from '@angular/core';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
/** Custom Services */
import { AmlService } from '../../../aml/aml.service';
import { AlertService } from '../../../core/alert/alert.service';
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
import { AmlRecurringReviewDialogComponent } from './aml-recurring-review-dialog.component';
import { amlRecurringAlertsDemoData } from './aml-recurring-alerts.demo-data';
/**
* Customers whose transactions keep tripping the AML engine, queued for an employee to review why.
*
* A single alert is a transaction question and is handled on the Flagged Transactions tab. This tab
* asks a different question — is this *customer's* pattern of behaviour acceptable — which no single
* alert can answer. Reviews are recorded against the alert count seen, so a customer leaves the
* queue once reviewed and only returns when the engine flags them again.
*/
@Component({
selector: 'mifosx-aml-recurring-alerts',
templateUrl: './aml-recurring-alerts.component.html',
styleUrls: ['./aml-recurring-alerts.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatTableModule,
MatIconModule,
MatButtonModule,
MatTooltipModule,
MatFormFieldModule,
MatSelectModule,
MatCheckboxModule,
MatProgressSpinnerModule,
MatDialogModule,
HasPermissionDirective
]
})
export class AmlRecurringAlertsComponent implements OnInit {
private amlService = inject(AmlService);
private dialog = inject(MatDialog);
private alertService = inject(AlertService);
rows = new MatTableDataSource<any>([]);
displayedColumns: string[] = [
'customer',
'alertCount',
'severity',
'rules',
'totalAmount',
'lastAlert',
'reviewState',
'actions'
];
/**
* Matches ClientRiskEscalationServiceImpl.ESCALATION_THRESHOLD, which already treats 3 cumulative
* AML events as the point a customer's risk rating is reconsidered.
*/
minAlerts = 3;
windowDays = 90;
onlyNeedingReview = false;
readonly thresholdOptions = [
2,
3,
5,
10
];
readonly windowOptions = [
{ value: 30, label: 'Last 30 days' },
{ value: 90, label: 'Last 90 days' },
{ value: 180, label: 'Last 6 months' },
{ value: 365, label: 'Last 12 months' }
];
loading = false;
loadFailed = false;
/** Set when the rows on screen are the sample set rather than engine output. */
usingDemoData = false;
/** Which customer's alert detail is expanded inline. */
expandedClientId: number | null = null;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.loadFailed = false;
this.expandedClientId = null;
this.amlService.getRecurringAlerts(this.minAlerts, this.windowDays, this.onlyNeedingReview).subscribe({
next: (data) => {
this.loading = false;
const rows = data || [];
this.usingDemoData = rows.length === 0;
this.rows.data = this.mapRows(this.usingDemoData ? this.demoRows() : rows);
},
error: () => {
this.loading = false;
this.loadFailed = true;
this.usingDemoData = true;
this.rows.data = this.mapRows(this.demoRows());
}
});
}
/** Parses the date fields the template renders. Shared by engine rows and the sample set. */
private mapRows(rows: any[]): any[] {
return rows.map((r: any) => ({
...r,
firstAlertOnDate: this.toDate(r.firstAlertOn),
lastAlertOnDate: this.toDate(r.lastAlertOn),
lastReviewedOnDate: this.toDate(r.lastReviewedOn),
alerts: (r.alerts || []).map((a: any) => ({ ...a, raisedOnDate: this.toDate(a.raisedOn) }))
}));
}
/**
* The sample set narrowed by the on-screen controls, so the threshold and window selectors behave
* the way they would against the engine instead of returning the same three rows regardless.
*/
private demoRows(): any[] {
const cutoff = Date.now() - this.windowDays * 24 * 60 * 60 * 1000;
return amlRecurringAlertsDemoData()
.map((row: any) => {
const alerts = (row.alerts || []).filter((a: any) => new Date(a.raisedOn).getTime() >= cutoff);
const ruleCodes = Array.from(new Set(alerts.map((a: any) => a.ruleCode)));
return {
...row,
alerts,
alertCount: alerts.length,
openCount: alerts.filter((a: any) => a.status === 'OPEN').length,
escalatedCount: alerts.filter((a: any) => a.status === 'ESCALATED').length,
// Severity sits on the alert server-side; the sample rows carry a row total, so it is
// capped at what survived the window rather than recomputed.
highRiskCount: Math.min(row.highRiskCount, alerts.length),
distinctRules: ruleCodes.length,
ruleCodes,
totalAmount: alerts.reduce((sum: number, a: any) => sum + (Number(a.amount) || 0), 0),
firstAlertOn: alerts.length ? alerts[0].raisedOn : null,
lastAlertOn: alerts.length ? alerts[alerts.length - 1].raisedOn : null
};
})
.filter((row: any) => row.alertCount >= this.minAlerts)
.filter((row: any) => !this.onlyNeedingReview || row.needsReview);
}
toggleExpanded(row: any): void {
this.expandedClientId = this.expandedClientId === row.clientId ? null : row.clientId;
}
isExpanded(row: any): boolean {
return this.expandedClientId === row.clientId;
}
get needingReviewCount(): number {
return this.rows.data.filter((r) => r.needsReview).length;
}
review(row: any): void {
const dialogRef = this.dialog.open(AmlRecurringReviewDialogComponent, {
width: '780px',
maxWidth: '95vw',
autoFocus: false,
disableClose: true,
data: { row, windowDays: this.windowDays }
});
dialogRef.afterClosed().subscribe((outcome: string | undefined) => {
if (!outcome) {
return;
}
this.alertService.alert({
type: 'Review Recorded',
message: `${row.clientDisplayName} reviewed as ${outcome}.`
});
// Re-read: the review changes needsReview, and the count recorded server-side is what the
// row must now reflect.
this.load();
});
}
amount(value: any): string {
return '₹' + (Number(value) || 0).toLocaleString('en-IN');
}
riskLabel(category: any): string {
switch (Number(category)) {
case 1:
return 'Low';
case 2:
return 'Medium';
case 3:
return 'High';
default:
return 'Unrated';
}
}
riskClass(category: any): string {
switch (Number(category)) {
case 1:
return 'ra-risk ra-risk-low';
case 2:
return 'ra-risk ra-risk-medium';
case 3:
return 'ra-risk ra-risk-high';
default:
return 'ra-risk ra-risk-none';
}
}
/**
* Fineract's Gson adapter emits LocalDateTime as [y, M, d, H, m, s] rather than an ISO string, so
* the date pipe renders nothing unless it is converted first. Month is 1-based there, 0-based in Date.
*/
private toDate(value: any): Date | null {
if (!value) {
return null;
}
if (Array.isArray(value)) {
const [
y,
M,
d,
H = 0,
m = 0,
s = 0
] = value;
if (y == null || M == null || d == null) {
return null;
}
return new Date(y, M - 1, d, H, m, s);
}
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
}
@@ -0,0 +1,248 @@
/**
* 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/.
*/
/**
* Sample recurring-alert rows for the AML review queue, used only when the engine returns nothing
* for the selected window so the screen can be demonstrated and reviewed.
*
* Shaped exactly like the `/aml/recurring-alerts` response — dates as ISO strings, alerts nested —
* so the rows travel through the component's normal mapping rather than a second code path.
*
* Dates are generated relative to today rather than hard-coded, otherwise every row would fall
* outside the "last 90 days" window as soon as the file aged.
*/
/** ISO timestamp `days` before now, at the given hour and minute. */
function daysAgo(days: number, hour = 11, minute = 20): string {
const d = new Date();
d.setDate(d.getDate() - days);
d.setHours(hour, minute, 0, 0);
return d.toISOString();
}
/**
* Three customers covering the three review states the table renders: never reviewed, reviewed but
* flagged again since, and reviewed with an outcome recorded.
*/
export function amlRecurringAlertsDemoData(): any[] {
return [
// Never reviewed — the row the queue exists to surface.
{
clientId: 10078,
clientDisplayName: 'Ravi Kumar Sharma',
accountNo: '000000010078',
clientRiskCategory: 3,
alertCount: 5,
highRiskCount: 3,
openCount: 4,
escalatedCount: 1,
distinctRules: 3,
ruleCodes: [
'CASH_TXN_10L',
'STRUCTURING',
'RAPID_MOVEMENT'
],
totalAmount: 4785000,
firstAlertOn: daysAgo(62, 10, 5),
lastAlertOn: daysAgo(3, 15, 42),
needsReview: true,
lastReviewedOn: null,
alertCountAtLastReview: null,
lastReviewOutcome: null,
lastReviewedByUsername: null,
lastReviewNotes: null,
alerts: [
{
alertId: 90121,
raisedOn: daysAgo(62, 10, 5),
transactionType: 'Savings Deposit',
transactionId: 552301,
ruleCode: 'CASH_TXN_10L',
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
amount: 1200000,
status: 'OPEN',
reviewNotes: null
},
{
alertId: 90144,
raisedOn: daysAgo(48, 12, 18),
transactionType: 'Savings Deposit',
transactionId: 553980,
ruleCode: 'STRUCTURING',
ruleDescription: 'Multiple cash deposits just below the reporting threshold within 48 hours',
amount: 890000,
status: 'OPEN',
reviewNotes: null
},
{
alertId: 90190,
raisedOn: daysAgo(29, 9, 55),
transactionType: 'Savings Withdrawal',
transactionId: 556114,
ruleCode: 'RAPID_MOVEMENT',
ruleDescription: 'Funds withdrawn within 24 hours of a large credit',
amount: 1150000,
status: 'ESCALATED',
reviewNotes: 'Escalated to Principal Officer — source of funds not established.'
},
{
alertId: 90233,
raisedOn: daysAgo(14, 16, 30),
transactionType: 'Savings Deposit',
transactionId: 558702,
ruleCode: 'CASH_TXN_10L',
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
amount: 1045000,
status: 'OPEN',
reviewNotes: null
},
{
alertId: 90275,
raisedOn: daysAgo(3, 15, 42),
transactionType: 'Savings Withdrawal',
transactionId: 560488,
ruleCode: 'STRUCTURING',
ruleDescription: 'Multiple cash deposits just below the reporting threshold within 48 hours',
amount: 500000,
status: 'OPEN',
reviewNotes: null
}
]
},
// Reviewed at 2 alerts, flagged twice since — returns to the queue as "New activity".
{
clientId: 10079,
clientDisplayName: 'Priya Patel',
accountNo: '000000010079',
clientRiskCategory: 2,
alertCount: 4,
highRiskCount: 1,
openCount: 2,
escalatedCount: 0,
distinctRules: 2,
ruleCodes: [
'HIGH_VALUE_TRANSFER',
'ROUND_TRIPPING'
],
totalAmount: 2130000,
firstAlertOn: daysAgo(71, 14, 10),
lastAlertOn: daysAgo(6, 11, 5),
needsReview: true,
lastReviewedOn: daysAgo(34, 17, 0),
alertCountAtLastReview: 2,
lastReviewOutcome: 'MONITOR',
lastReviewedByUsername: 'anjali.mehta',
lastReviewNotes: 'Textile trading receipts, invoices seen. Keep under watch for one more quarter.',
alerts: [
{
alertId: 90102,
raisedOn: daysAgo(71, 14, 10),
transactionType: 'Account Transfer',
transactionId: 551002,
ruleCode: 'HIGH_VALUE_TRANSFER',
ruleDescription: 'Transfer above ₹5,00,000 to an unrelated account',
amount: 620000,
status: 'WHITELISTED',
reviewNotes: 'Supplier payment, invoice verified.'
},
{
alertId: 90158,
raisedOn: daysAgo(41, 10, 45),
transactionType: 'Account Transfer',
transactionId: 554330,
ruleCode: 'ROUND_TRIPPING',
ruleDescription: 'Funds returned to the originating account within seven days',
amount: 450000,
status: 'WHITELISTED',
reviewNotes: 'Advance refunded after order cancellation.'
},
{
alertId: 90241,
raisedOn: daysAgo(19, 13, 25),
transactionType: 'Account Transfer',
transactionId: 559011,
ruleCode: 'HIGH_VALUE_TRANSFER',
ruleDescription: 'Transfer above ₹5,00,000 to an unrelated account',
amount: 540000,
status: 'OPEN',
reviewNotes: null
},
{
alertId: 90268,
raisedOn: daysAgo(6, 11, 5),
transactionType: 'Account Transfer',
transactionId: 560190,
ruleCode: 'ROUND_TRIPPING',
ruleDescription: 'Funds returned to the originating account within seven days',
amount: 520000,
status: 'OPEN',
reviewNotes: null
}
]
},
// Reviewed with an outcome and no activity since — shows the settled state.
{
clientId: 10080,
clientDisplayName: 'Arjun Deshmukh',
accountNo: '000000010080',
clientRiskCategory: 1,
alertCount: 3,
highRiskCount: 0,
openCount: 0,
escalatedCount: 0,
distinctRules: 1,
ruleCodes: ['CASH_TXN_10L'],
totalAmount: 3420000,
firstAlertOn: daysAgo(80, 9, 30),
lastAlertOn: daysAgo(23, 12, 15),
needsReview: false,
lastReviewedOn: daysAgo(9, 15, 20),
alertCountAtLastReview: 3,
lastReviewOutcome: 'CLEARED',
lastReviewedByUsername: 'vikram.singh',
lastReviewNotes: 'Sugarcane crushing-season proceeds. Mill settlement statements filed with the branch.',
alerts: [
{
alertId: 90088,
raisedOn: daysAgo(80, 9, 30),
transactionType: 'Savings Deposit',
transactionId: 550210,
ruleCode: 'CASH_TXN_10L',
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
amount: 1180000,
status: 'WHITELISTED',
reviewNotes: 'Mill settlement for the 2025-26 crushing season.'
},
{
alertId: 90165,
raisedOn: daysAgo(52, 11, 0),
transactionType: 'Savings Deposit',
transactionId: 554901,
ruleCode: 'CASH_TXN_10L',
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
amount: 1090000,
status: 'WHITELISTED',
reviewNotes: 'Second instalment, statement on record.'
},
{
alertId: 90212,
raisedOn: daysAgo(23, 12, 15),
transactionType: 'Savings Deposit',
transactionId: 557640,
ruleCode: 'CASH_TXN_10L',
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
amount: 1150000,
status: 'WHITELISTED',
reviewNotes: 'Final instalment for the season.'
}
]
}
];
}
@@ -0,0 +1,127 @@
<!--
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/.
-->
<h2 mat-dialog-title class="rr-dialog-title">
<mat-icon>fact_check</mat-icon>
Review Repeat Activity — {{ data.row.clientDisplayName }}
</h2>
<mat-dialog-content class="rr-dialog-content">
<div class="rr-summary">
<div class="rr-summary-row">
<span class="rr-label">Customer</span>
<span class="rr-value">
{{ data.row.clientDisplayName }}
<span class="rr-muted"
>(ID {{ data.row.clientId }}{{ data.row.accountNo ? ' · ' + data.row.accountNo : '' }})</span
>
</span>
</div>
<div class="rr-summary-row">
<span class="rr-label">Alerts</span>
<span class="rr-value">
<strong>{{ data.row.alertCount }}</strong> in the last {{ data.windowDays }} days ·
{{ data.row.highRiskCount }} High risk · {{ data.row.distinctRules }} distinct rule(s)
</span>
</div>
<div class="rr-summary-row">
<span class="rr-label">Value</span>
<span class="rr-value rr-mono">{{ amount(data.row.totalAmount) }}</span>
</div>
<div class="rr-summary-row">
<span class="rr-label">Rules</span>
<span class="rr-value">{{ data.row.ruleCodes?.join(', ') || '—' }}</span>
</div>
</div>
<!-- The triggering transactions, so the decision is made against the evidence -->
<div class="rr-alerts">
<span class="rr-alerts-legend">Triggering transactions</span>
<div class="rr-alerts-scroll">
<table class="rr-alerts-tbl">
<thead>
<tr>
<th>Raised On</th>
<th>Transaction</th>
<th>Rule</th>
<th class="ta-r">Amount</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let a of data.row.alerts">
<td>{{ a.raisedOnDate | date: 'dd MMM yyyy, HH:mm' }}</td>
<td>{{ a.transactionType }} #{{ a.transactionId }}</td>
<td>{{ a.ruleCode }}</td>
<td class="ta-r rr-mono">{{ amount(a.amount) }}</td>
<td>{{ a.status }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div *ngIf="data.row.lastReviewedOn" class="rr-prior">
<mat-icon>history</mat-icon>
<span>
Last reviewed by <strong>{{ data.row.lastReviewedByUsername }}</strong> on
{{ data.row.lastReviewedOnDate | date: 'dd MMM yyyy' }} at {{ data.row.alertCountAtLastReview }} alert(s) —
{{ data.row.lastReviewOutcome }}. {{ data.row.lastReviewNotes }}
</span>
</div>
<div *ngIf="errorMessage" class="rr-error">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMessage }}</span>
</div>
<form [formGroup]="reviewForm" autocomplete="off">
<div class="rr-outcome">
<span class="rr-outcome-legend">Conclusion</span>
<mat-radio-group formControlName="outcome" class="rr-outcome-options">
<mat-radio-button *ngFor="let o of outcomes" [value]="o.value" class="rr-outcome-option">
{{ o.label }}
<span class="rr-muted">— {{ o.hint }}</span>
</mat-radio-button>
</mat-radio-group>
</div>
<mat-form-field appearance="outline" class="rr-notes-field">
<mat-label>Why does this customer keep surfacing?</mat-label>
<textarea
matInput
rows="4"
formControlName="notes"
placeholder="What the pattern is, what was checked, and what was decided"
></textarea>
<mat-error *ngIf="reviewForm.get('notes')?.hasError('required')">
Required — this is the audit record for the decision
</mat-error>
<mat-error *ngIf="reviewForm.get('notes')?.hasError('maxlength')">Keep this under 1000 characters</mat-error>
</mat-form-field>
<p class="rr-note">
Recorded against the current count of {{ data.row.alertCount }} alert(s). The customer returns to this queue if
the engine flags them again.
</p>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end" class="rr-dialog-actions">
<button mat-stroked-button type="button" [disabled]="submitting" (click)="cancel()">Cancel</button>
<button
mat-flat-button
color="primary"
type="button"
[disabled]="reviewForm.invalid || submitting"
(click)="submit()"
>
<mat-spinner *ngIf="submitting" diameter="18" class="rr-spinner"></mat-spinner>
{{ submitting ? 'Saving…' : 'Record Review' }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,191 @@
/**
* 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/.
*/
.rr-dialog-title {
display: flex;
align-items: center;
gap: 10px;
color: #1a237e;
font-weight: 600;
}
.rr-dialog-content {
padding-top: 8px;
min-width: 620px;
max-width: 780px;
}
.rr-summary {
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
padding: 14px 16px;
margin-bottom: 16px;
}
.rr-summary-row {
display: flex;
gap: 12px;
padding: 4px 0;
font-size: 13px;
align-items: baseline;
}
.rr-label {
flex: 0 0 84px;
color: #64748b;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.rr-value {
flex: 1 1 auto;
color: #1e293b;
}
.rr-muted {
color: #64748b;
font-weight: 400;
}
.rr-mono {
font-variant-numeric: tabular-nums;
}
.rr-alerts {
margin-bottom: 16px;
}
.rr-alerts-legend,
.rr-outcome-legend {
display: block;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin-bottom: 6px;
}
/* Capped so a customer with twenty alerts cannot push the buttons off screen. */
.rr-alerts-scroll {
max-height: 190px;
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.rr-alerts-tbl {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th {
position: sticky;
top: 0;
background: #f1f5f9;
color: #475569;
font-weight: 700;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 8px 10px;
text-align: left;
}
td {
padding: 8px 10px;
border-top: 1px solid #f1f5f9;
color: #1e293b;
white-space: nowrap;
}
}
.ta-r {
text-align: right;
}
.rr-prior,
.rr-error {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
margin-bottom: 16px;
border-radius: 6px;
font-size: 12.5px;
line-height: 1.45;
mat-icon {
flex: 0 0 auto;
font-size: 20px;
width: 20px;
height: 20px;
}
}
.rr-prior {
border: 1px solid #cbd5e1;
background: #f8fafc;
color: #475569;
}
.rr-error {
border: 1px solid #fecaca;
background: #fef2f2;
color: #b91c1c;
font-weight: 500;
}
.rr-outcome {
margin-bottom: 12px;
}
.rr-outcome-options {
display: flex;
flex-direction: column;
gap: 2px;
}
.rr-outcome-option {
font-size: 13px;
}
.rr-notes-field {
width: 100%;
}
.rr-note {
margin: 4px 0 0;
font-size: 12px;
color: #64748b;
}
.rr-dialog-actions {
padding: 8px 24px 20px;
gap: 8px;
}
.rr-spinner {
display: inline-block;
margin-right: 8px;
vertical-align: middle;
}
@media (width <= 700px) {
.rr-dialog-content {
min-width: unset;
}
.rr-summary-row {
flex-direction: column;
gap: 2px;
}
}
@@ -0,0 +1,124 @@
/**
* 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, inject } from '@angular/core';
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatRadioModule } from '@angular/material/radio';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { CommonModule } from '@angular/common';
/** Custom Services */
import { AmlService } from '../../../aml/aml.service';
export interface AmlRecurringReviewDialogData {
row: any;
windowDays: number;
}
/**
* Records why a customer keeps tripping the AML engine.
*
* This is a judgement on the customer's pattern, not on any single transaction — the individual
* alerts are still disposed of one by one on the Flagged Transactions tab. Choosing ESCALATED here
* documents the reviewer's conclusion; it does not itself open an STR case, because a case is
* opened against a specific alert.
*/
@Component({
selector: 'mifosx-aml-recurring-review-dialog',
templateUrl: './aml-recurring-review-dialog.component.html',
styleUrls: ['./aml-recurring-review-dialog.component.scss'],
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatRadioModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule
]
})
export class AmlRecurringReviewDialogComponent {
private formBuilder = inject(FormBuilder);
private amlService = inject(AmlService);
private dialogRef = inject(MatDialogRef<AmlRecurringReviewDialogComponent>);
readonly data = inject<AmlRecurringReviewDialogData>(MAT_DIALOG_DATA);
submitting = false;
errorMessage = '';
readonly outcomes = [
{ value: 'CLEARED', label: 'Cleared', hint: 'Behaviour explained and acceptable' },
{ value: 'MONITOR', label: 'Keep monitoring', hint: 'Not conclusive — watch this customer more closely' },
{ value: 'ESCALATED', label: 'Escalate', hint: 'Warrants an STR case; escalate the alerts individually' }
];
reviewForm = this.formBuilder.group({
outcome: [
'MONITOR',
Validators.required
],
notes: [
'',
[
Validators.required,
Validators.maxLength(1000)
]
]
});
submit(): void {
if (this.reviewForm.invalid) {
this.reviewForm.markAllAsTouched();
return;
}
this.submitting = true;
this.errorMessage = '';
this.amlService
.reviewRecurringAlert(
this.data.row.clientId,
this.reviewForm.value.outcome as 'CLEARED' | 'MONITOR' | 'ESCALATED',
(this.reviewForm.value.notes || '').trim(),
this.data.windowDays
)
.subscribe({
next: () => {
this.submitting = false;
this.dialogRef.close(this.reviewForm.value.outcome);
},
error: (err) => {
this.submitting = false;
this.errorMessage = this.readError(err);
}
});
}
cancel(): void {
this.dialogRef.close();
}
amount(value: any): string {
return '₹' + (Number(value) || 0).toLocaleString('en-IN');
}
private readError(err: any): string {
const body = err?.error;
const first = body?.errors?.[0];
return (
first?.defaultUserMessage || first?.developerMessage || body?.defaultUserMessage || 'Could not record the review.'
);
}
}
@@ -18,119 +18,192 @@
</mat-card-subtitle>
</div>
</div>
<button mat-flat-button type="button" class="add-rule-button" (click)="addRule()">
<mat-icon>add</mat-icon>
Add Rule
</button>
</mat-card-header>
<mat-card-content class="aml-card-content">
<!-- Separate the rules by the window they are measured over -->
<div class="period-filters">
<span class="period-filters-label">Applies over</span>
<button
*ngFor="let p of periodFilters"
type="button"
class="period-chip"
[class.active]="activePeriod === p.key"
[disabled]="periodCount(p.key) === 0"
(click)="setPeriod(p.key)"
>
{{ p.label }}
<span class="period-chip-n">{{ periodCount(p.key) }}</span>
</button>
</div>
<mat-tab-group class="aml-tabs" animationDuration="0ms">
<mat-tab label="Rules Master">
<div class="aml-tab-body">
<!-- Free-text search across every column the table renders -->
<div class="aml-search-bar">
<mat-form-field appearance="outline" class="aml-search-field" subscriptSizing="dynamic">
<mat-label>Search rules</mat-label>
<mat-icon matPrefix class="aml-search-icon">search</mat-icon>
<input
matInput
type="text"
autocomplete="off"
placeholder="Rule, reason, risk, limit, window, status…"
[ngModel]="searchTerm"
(ngModelChange)="applySearch($event)"
/>
<button
*ngIf="searchTerm"
matSuffix
mat-icon-button
type="button"
aria-label="Clear search"
matTooltip="Clear search"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
<span *ngIf="isSearching" class="aml-search-count">
Matching <strong>{{ matchedRules }}</strong> of {{ totalRules }} rules
</span>
</div>
<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>
<!-- Separate the rules by the window they are measured over -->
<div class="period-filters">
<span class="period-filters-label">Applies over</span>
<button
*ngFor="let p of periodFilters"
type="button"
class="period-chip"
[class.active]="activePeriod === p.key"
[disabled]="periodCount(p.key) === 0 && activePeriod !== p.key"
(click)="setPeriod(p.key)"
>
{{ p.label }}
<span class="period-chip-n">{{ periodCount(p.key) }}</span>
</button>
</div>
<!-- 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>
<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>
<!-- 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>
<!-- 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>
<!-- What the min/max figures are measured against -->
<ng-container matColumnDef="limitBasis">
<th mat-header-cell *matHeaderCellDef>Limit Basis</th>
<td mat-cell *matCellDef="let element" class="basis-cell">
{{ getLimitBasis(element) }}
<span *ngIf="getNonNumericCriterion(element)" class="basis-sub">
{{ getNonNumericCriterion(element) }}
</span>
</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>
<!-- Minimum Limit Column -->
<ng-container matColumnDef="minLimit">
<th mat-header-cell *matHeaderCellDef class="col-right">Minimum Limit</th>
<td mat-cell *matCellDef="let element" class="col-right amount-cell">{{ getMinLimit(element) }}</td>
</ng-container>
<!-- What the min/max figures are measured against -->
<ng-container matColumnDef="limitBasis">
<th mat-header-cell *matHeaderCellDef>Limit Basis</th>
<td mat-cell *matCellDef="let element" class="basis-cell">
{{ getLimitBasis(element) }}
<span *ngIf="getNonNumericCriterion(element)" class="basis-sub">
{{ getNonNumericCriterion(element) }}
</span>
</td>
</ng-container>
<!-- Maximum Limit Column -->
<ng-container matColumnDef="maxLimit">
<th mat-header-cell *matHeaderCellDef class="col-right">Maximum Limit</th>
<td mat-cell *matCellDef="let element" class="col-right amount-cell">
<span [class.no-ceiling]="getMaxLimit(element) === 'No upper limit'">{{ getMaxLimit(element) }}</span>
</td>
</ng-container>
<!-- Minimum Limit Column -->
<ng-container matColumnDef="minLimit">
<th mat-header-cell *matHeaderCellDef class="col-right">Minimum Limit</th>
<td mat-cell *matCellDef="let element" class="col-right amount-cell">{{ getMinLimit(element) }}</td>
</ng-container>
<!-- Observation window the rule is measured over -->
<ng-container matColumnDef="appliesOver">
<th mat-header-cell *matHeaderCellDef class="col-center">Applies Over</th>
<td mat-cell *matCellDef="let element" class="col-center">
<span [ngClass]="periodClass(element)">{{ periodLabel(element) }}</span>
</td>
</ng-container>
<!-- Maximum Limit Column -->
<ng-container matColumnDef="maxLimit">
<th mat-header-cell *matHeaderCellDef class="col-right">Maximum Limit</th>
<td mat-cell *matCellDef="let element" class="col-right amount-cell">
<span [class.no-ceiling]="getMaxLimit(element) === 'No upper limit'">{{ getMaxLimit(element) }}</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>
<!-- Observation window the rule is measured over -->
<ng-container matColumnDef="appliesOver">
<th mat-header-cell *matHeaderCellDef class="col-center">Applies Over</th>
<td mat-cell *matCellDef="let element" class="col-center">
<span [ngClass]="periodClass(element)">{{ periodLabel(element) }}</span>
</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>
<!-- 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>
<!-- 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>
<!-- 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>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="rule-row"></tr>
</table>
<!-- 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>
<!-- Mat Paginator for standard pagination -->
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons class="aml-paginator"></mat-paginator>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="rule-row"></tr>
</table>
<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 Paginator for standard pagination -->
<mat-paginator
[pageSizeOptions]="[10, 25, 50, 100]"
showFirstLastButtons
class="aml-paginator"
></mat-paginator>
<div *ngIf="rules.filteredData.length === 0" class="no-rules-message">
<mat-icon>{{ isSearching ? 'search_off' : 'info_outline' }}</mat-icon>
<span *ngIf="!isSearching">No AML rules found in the database.</span>
<ng-container *ngIf="isSearching">
<span>No rules match “{{ searchTerm }}”.</span>
<button mat-stroked-button type="button" (click)="clearSearch()">Clear search</button>
</ng-container>
</div>
</div>
</mat-tab>
<!-- Live feed of what the engine has actually flagged. Lazily instantiated via
matTabContent so the poll only runs while the officer is watching this tab. -->
<mat-tab label="Flagged Transactions">
<ng-template matTabContent>
<div class="aml-tab-body">
<mifosx-aml-flagged-transactions></mifosx-aml-flagged-transactions>
</div>
</ng-template>
</mat-tab>
<!-- Customers who keep tripping the engine, queued for an employee to review why. -->
<mat-tab label="Repeat Customers">
<ng-template matTabContent>
<div class="aml-tab-body">
<mifosx-aml-recurring-alerts></mifosx-aml-recurring-alerts>
</div>
</ng-template>
</mat-tab>
</mat-tab-group>
</mat-card-content>
</mat-card>
</div>
@@ -26,6 +26,28 @@
background: linear-gradient(135deg, #1e293b, #0f172a);
color: #fff;
padding: 24px;
/* Title on the left, the add action pinned to the right edge of the banner. */
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.add-rule-button {
flex: 0 0 auto;
background: #38bdf8;
color: #0f172a;
font-weight: 600;
mat-icon {
margin-right: 6px;
}
&:hover {
background: #0ea5e9;
}
}
.aml-header-content {
@@ -58,6 +80,18 @@
padding: 24px;
}
/* Rules Master / Flagged Transactions. Padding lives on the body rather than the group so the
tab header rule still spans the full card width. */
.aml-tabs {
::ng-deep .mat-mdc-tab-body-wrapper {
padding-top: 4px;
}
}
.aml-tab-body {
padding-top: 16px;
}
.aml-table {
width: 100%;
border-collapse: separate;
@@ -187,6 +221,35 @@
}
}
/* Free-text search over the rule list. */
.aml-search-bar {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
padding: 0 0 14px;
}
.aml-search-field {
flex: 1 1 340px;
max-width: 480px;
}
.aml-search-icon {
color: #64748b;
margin-right: 8px;
}
.aml-search-count {
font-size: 12.5px;
color: #64748b;
strong {
color: #1a237e;
font-weight: 700;
}
}
/* Chips that separate the rule list by window. */
.period-filters {
display: flex;
@@ -14,10 +14,20 @@ 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 { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatTabsModule } from '@angular/material/tabs';
import { FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
/** Custom Services */
import { ClientsService } from '../../clients/clients.service';
import { AlertService } from '../../core/alert/alert.service';
import { AddAmlRuleDialogComponent } from './add-aml-rule-dialog/add-aml-rule-dialog.component';
import { AmlFlaggedTransactionsComponent } from './aml-flagged-transactions/aml-flagged-transactions.component';
import { AmlRecurringAlertsComponent } from './aml-recurring-alerts/aml-recurring-alerts.component';
@Component({
selector: 'mifosx-aml-rules',
@@ -31,11 +41,21 @@ import { ClientsService } from '../../clients/clients.service';
MatIconModule,
MatButtonModule,
MatCheckboxModule,
MatPaginatorModule
MatPaginatorModule,
MatFormFieldModule,
MatInputModule,
MatTooltipModule,
MatDialogModule,
MatTabsModule,
FormsModule,
AmlFlaggedTransactionsComponent,
AmlRecurringAlertsComponent
]
})
export class AmlRulesComponent implements OnInit {
private clientsService = inject(ClientsService);
private dialog = inject(MatDialog);
private alertService = inject(AlertService);
rules = new MatTableDataSource<any>([]);
displayedColumns: string[] = [
@@ -65,9 +85,27 @@ export class AmlRulesComponent implements OnInit {
];
activePeriod = '';
/** Free-text search, applied on top of the period filter. */
searchTerm = '';
/**
* Rule count per period bucket, keyed by period. Precomputed on every filter change
* rather than counted from the template — the chips are re-read on each change
* detection pass and re-scanning every rule six times over is wasted work.
*/
private periodCounts: Record<string, number> = {};
@ViewChild(MatPaginator) paginator!: MatPaginator;
ngOnInit(): void {
// The visible table is the rendered text, not the raw row — a reader searching
// "high risk" or "monthly" is matching on labels this component derives, so the
// predicate has to search the same strings it puts on screen.
this.rules.filterPredicate = (rule: any, filter: string) => this.matchesSearch(rule, filter);
this.loadRules();
}
private loadRules(): void {
this.clientsService.getClientRiskRules().subscribe({
next: (data) => {
this.allRules = data || [];
@@ -78,15 +116,65 @@ export class AmlRulesComponent implements OnInit {
});
}
addRule(): void {
const dialogRef = this.dialog.open(AddAmlRuleDialogComponent, {
width: '760px',
maxWidth: '95vw',
autoFocus: false,
// The officer has typed a full rule by the time they reach the buttons; a stray
// backdrop click should not discard it.
disableClose: true
});
dialogRef.afterClosed().subscribe((created: any) => {
if (!created) {
return;
}
this.alertService.alert({
type: 'AML Rule Created',
message: `Rule "${created.ruleName}" added to the catalog.`
});
// Re-fetch rather than splice the new rule in: order_position is assigned server side
// and the transaction-rule join decides the Applies Over / limit columns.
this.loadRules();
});
}
setPeriod(key: string): void {
this.activePeriod = key;
this.applyPeriodFilter();
}
applySearch(value: string): void {
this.searchTerm = value;
this.rules.filter = value.trim().toLowerCase();
this.recomputePeriodCounts();
if (this.rules.paginator) {
this.rules.paginator.firstPage();
}
}
clearSearch(): void {
this.applySearch('');
}
get totalRules(): number {
return this.allRules.length;
}
get matchedRules(): number {
return this.rules.filteredData.length;
}
get isSearching(): boolean {
return !!this.rules.filter;
}
private applyPeriodFilter(): void {
this.rules.data = this.activePeriod
? this.allRules.filter((r) => this.periodKey(r) === this.activePeriod)
: this.allRules;
this.recomputePeriodCounts();
if (this.rules.paginator) {
this.rules.paginator.firstPage();
}
@@ -94,7 +182,58 @@ export class AmlRulesComponent implements OnInit {
/** How many rules fall in each period bucket — shown on the filter chips. */
periodCount(key: string): number {
return key ? this.allRules.filter((r) => this.periodKey(r) === key).length : this.allRules.length;
return this.periodCounts[key] ?? 0;
}
/**
* Counts are taken over the search results, not the whole set, so a chip never
* advertises rules the current search has filtered out.
*/
private recomputePeriodCounts(): void {
const filter = this.rules.filter;
const pool = filter ? this.allRules.filter((r) => this.matchesSearch(r, filter)) : this.allRules;
this.periodCounts = { '': pool.length };
for (const p of this.periodFilters) {
if (p.key) {
this.periodCounts[p.key] = pool.filter((r) => this.periodKey(r) === p.key).length;
}
}
}
/** Every term must match, so "high monthly" narrows instead of widening. */
private matchesSearch(rule: any, filter: string): boolean {
const terms = filter.split(/\s+/).filter((t) => t.length > 0);
if (terms.length === 0) {
return true;
}
const haystack = this.searchableText(rule);
return terms.every((t) => haystack.includes(t));
}
/** Everything the row displays, flattened into one lower-case string. */
private searchableText(rule: any): string {
const min = this.getMinLimit(rule);
const max = this.getMaxLimit(rule);
return [
rule.orderPosition,
rule.ruleName,
rule.reason,
this.getRiskCategoryLabel(rule.riskCategory),
this.getLimitBasis(rule),
this.getNonNumericCriterion(rule),
min,
max,
// Amounts are rendered grouped ("₹10,00,000"); also index the bare digits so
// typing 1000000 finds them.
min.replace(/\D/g, ''),
max.replace(/\D/g, ''),
this.periodLabel(rule),
rule.amlCheckStatus || 'CLEAR',
rule.bannedEntityMatch || 'NO'
]
.filter((v) => v != null && v !== '')
.join(' ')
.toLowerCase();
}
/**
@@ -10,7 +10,7 @@
<div class="ed-icon">
<fa-icon icon="exclamation-circle"></fa-icon>
</div>
<h2 class="ed-title">We encountered an issue. Please try again.</h2>
<h2 class="ed-title">{{ title }}</h2>
@if (!showAsCode) {
<p class="ed-message">{{ displayData }}</p>
}
@@ -45,10 +45,17 @@ export class ErrorDialogComponent {
* @param {DomSanitizer} sanitizer Service to sanitize HTML content.
*/
constructor() {
const data = this.data;
// Guard for non-string data to avoid runtime error
this.showAsCode = typeof data === 'string' && data.startsWith('<pre><code>');
this.showAsCode = this.displayData.startsWith('<pre><code>');
}
/**
* Heading for the dialog. Callers passing a plain string (the long-standing contract) keep the
* generic wording; callers with a specific failure to name can pass { title, message }.
*/
get title(): string {
const title = typeof this.data === 'object' && this.data !== null ? this.data.title : null;
return title || 'We encountered an issue. Please try again.';
}
/**
@@ -59,6 +66,9 @@ export class ErrorDialogComponent {
if (typeof this.data === 'string') {
return this.data;
}
if (typeof this.data === 'object' && this.data !== null && 'message' in this.data) {
return this.data.message ?? '';
}
// Convert non-string data to string representation for display
return this.data != null ? JSON.stringify(this.data) : '';
}
@@ -104,8 +104,8 @@
{{ element.displayName }}
</td>
</ng-container>
<ng-container matColumnDef="clientId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Client ID' | translate }}</th>
<ng-container matColumnDef="cifNumber">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.CIF Number' | translate }}</th>
<td
mat-cell
*matCellDef="let element"
@@ -113,7 +113,7 @@
[routerLink]="['../../clients', element.id]"
style="cursor: pointer"
>
{{ element.id }}
{{ element.cifNumber }}
</td>
</ng-container>
<ng-container matColumnDef="staff">
@@ -33,6 +33,7 @@ import { DatepickerBase } from 'app/shared/form-dialog/formfield/model/datepicke
/** Custom Services */
import { TasksService } from '../../tasks.service';
import { SettingsService } from 'app/settings/settings.service';
import { AlertService } from 'app/core/alert/alert.service';
import { Dates } from 'app/core/utils/dates';
import { KeyValuePipe } from '@angular/common';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@@ -74,6 +75,7 @@ export class ClientApprovalComponent implements OnInit {
private dateUtils = inject(Dates);
private settingsService = inject(SettingsService);
private tasksService = inject(TasksService);
private alertService = inject(AlertService);
/** Current page of pending clients (from the server), grouped by office for display */
groupedClients: any = {};
@@ -89,7 +91,7 @@ export class ClientApprovalComponent implements OnInit {
displayedColumns: string[] = [
'select',
'name',
'clientId',
'cifNumber',
'staff'
];
@@ -221,11 +223,15 @@ export class ClientApprovalComponent implements OnInit {
const data = {
title: 'Enter Clients Activation Date',
layout: { addButtonText: 'Confirm' },
formfields: formfields
formfields: formfields,
// The date is pre-filled with today, so the form is already valid on open. Without this the
// dialog stays pristine and Confirm is disabled until the user needlessly re-picks the date.
pristine: false
};
const clientApprovalDialogRef = this.dialog.open(FormDialogComponent, { data });
clientApprovalDialogRef.afterClosed().subscribe((response: any) => {
if (response.data) {
// Cancel / the X button close with no value, so response is undefined there.
if (response?.data) {
this.bulkClientApproval(response.data);
}
});
@@ -240,31 +246,70 @@ export class ClientApprovalComponent implements OnInit {
activationDate,
locale
};
const selectedAccounts = this.selection.selected.length;
const listSelectedAccounts = this.selection.selected;
let activatedAccounts = 0;
this.batchRequests = [];
let reqId = 1;
// Keep the client each request belongs to, so a rejection can name who it was for.
const clientsByRequestId = new Map<number, any>();
listSelectedAccounts.forEach((element: any) => {
const url = 'clients/' + element.id + '?command=activate';
const bodyData = JSON.stringify(formData);
const batchData = { requestId: reqId++, relativeUrl: url, method: 'POST', body: bodyData };
clientsByRequestId.set(batchData.requestId, element);
this.batchRequests.push(batchData);
});
this.tasksService.submitBatchData(this.batchRequests).subscribe((response: any) => {
response.forEach((responseEle: any) => {
if (responseEle.statusCode === '200') {
let activatedAccounts = 0;
const failures: string[] = [];
(response ?? []).forEach((responseEle: any) => {
// The batch API returns statusCode as a number, so the old `=== '200'` string comparison
// never matched: successes were never counted (no refresh) and rejections were dropped
// entirely, which made a failed approval look like nothing had happened at all.
if (Number(responseEle.statusCode) === 200) {
activatedAccounts++;
responseEle.body = JSON.parse(responseEle.body);
if (selectedAccounts === activatedAccounts) {
this.selection.clear();
this.reload();
}
} else {
const client = clientsByRequestId.get(Number(responseEle.requestId));
const name = client?.displayName ?? `Client ${client?.id ?? ''}`.trim();
failures.push(`${name}: ${this.batchErrorMessage(responseEle)}`);
}
});
if (failures.length) {
this.alertService.alert({
type: 'Client Approval Failed',
severity: 'error',
message:
(activatedAccounts ? `${activatedAccounts} approved. ` : '') +
`${failures.length} could not be approved — ${failures.join('; ')}`
});
}
// Refresh whenever anything changed, rather than only when every single one succeeded —
// a partial batch previously left the approved clients sitting in the list.
if (activatedAccounts) {
this.selection.clear();
this.reload();
}
});
}
/** Pulls the human-readable reason out of a failed batch entry's JSON body. */
private batchErrorMessage(responseEle: any): string {
try {
const body = typeof responseEle.body === 'string' ? JSON.parse(responseEle.body) : responseEle.body;
return (
body?.errors?.[0]?.defaultUserMessage ||
body?.errors?.[0]?.developerMessage ||
body?.defaultUserMessage ||
body?.developerMessage ||
`request failed with status ${responseEle.statusCode}`
);
} catch {
return `request failed with status ${responseEle.statusCode}`;
}
}
/**
* Refetches the current page after a bulk approval.
*/
@@ -80,7 +80,7 @@
<div class="tdsr__filters">
<label class="tdsr__filter">
<span>Financial year</span>
<select [(ngModel)]="filter.fy">
<select [(ngModel)]="filter.fy" (ngModelChange)="onFilterChanged()">
@for (year of financialYears; track year) {
<option [value]="year">FY {{ year }}</option>
}
@@ -90,7 +90,7 @@
@if (canSelectBranch) {
<label class="tdsr__filter">
<span>Branch</span>
<select [(ngModel)]="filter.branchCode">
<select [(ngModel)]="filter.branchCode" (ngModelChange)="onFilterChanged()">
<option value="">All branches</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
@@ -101,7 +101,7 @@
<label class="tdsr__filter">
<span>Quarter</span>
<select [(ngModel)]="filter.quarter">
<select [(ngModel)]="filter.quarter" (ngModelChange)="onFilterChanged()">
<option [ngValue]="0">Whole year</option>
@for (q of quarters; track q) {
<option [ngValue]="q">{{ quarterLabel[q] }}</option>
@@ -111,7 +111,7 @@
<label class="tdsr__filter">
<span>Threshold band</span>
<select [(ngModel)]="filter.band">
<select [(ngModel)]="filter.band" (ngModelChange)="onFilterChanged()">
<option value="">Both bands</option>
<option value="GENERAL">General (below 60)</option>
<option value="SENIOR">Senior citizen (60+)</option>
@@ -120,16 +120,21 @@
<label class="tdsr__filter tdsr__filter--wide">
<span>Customer / CIF / PAN</span>
<input type="text" [(ngModel)]="filter.search" placeholder="name, CIF or PAN" />
<input
type="text"
[(ngModel)]="filter.search"
(ngModelChange)="onFilterChanged()"
placeholder="name, CIF or PAN"
/>
</label>
<label class="tdsr__check">
<input type="checkbox" [(ngModel)]="filter.crossedOnly" />
<input type="checkbox" [(ngModel)]="filter.crossedOnly" (ngModelChange)="onFilterChanged()" />
<span>Threshold crossed only</span>
</label>
<label class="tdsr__check">
<input type="checkbox" [(ngModel)]="filter.nearThresholdOnly" />
<input type="checkbox" [(ngModel)]="filter.nearThresholdOnly" (ngModelChange)="onFilterChanged()" />
<span>Near threshold only</span>
</label>
@@ -138,6 +143,26 @@
}
</div>
<!-- Tabs Selector -->
<div class="tdsr__tabs">
<button
type="button"
class="tdsr__tab"
[class.tdsr__tab--active]="selectedTab === 'paying'"
(click)="selectTab('paying')"
>
Paying TDS ({{ payingRows.length }})
</button>
<button
type="button"
class="tdsr__tab"
[class.tdsr__tab--active]="selectedTab === 'not-paying'"
(click)="selectTab('not-paying')"
>
Not Paying TDS ({{ notPayingRows.length }})
</button>
</div>
<!-- Table -->
<div class="tdsr__card">
<div class="tdsr__table-wrap">
@@ -160,7 +185,7 @@
</tr>
</thead>
<tbody>
@for (row of rows; track trackRow($index, row)) {
@for (row of pagedRows; track trackRow($index, row)) {
<tr [class.tdsr__row--warn]="!row.panOnRecord" [class.tdsr__row--near]="row.nearThreshold">
<td>
<div class="tdsr__name">{{ row.deductee.name }}</div>
@@ -311,15 +336,70 @@
</tr>
}
}
@if (rows.length === 0) {
@if (visibleRows.length === 0) {
<tr>
<td colspan="13" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon> No deductees match the current filters.
<fa-icon icon="info-circle"></fa-icon> No customers in this section match the filters.
</td>
</tr>
}
</tbody>
</table>
</div>
@if (visibleRows.length > 0) {
<div class="tdsr__pager">
<div class="tdsr__pager-l">
<span>
Showing <strong>{{ rangeStart | number }}–{{ rangeEnd | number }}</strong> of
<strong>{{ visibleRows.length | number }}</strong>
{{ selectedTab === 'paying' ? 'deductees paying TDS' : 'deductees not paying TDS' }}
</span>
<label class="tdsr__pager-sizel">
Rows
<select class="tdsr__pager-size" [ngModel]="pageSize" (ngModelChange)="onPageSizeChange($event)">
@for (size of pageSizeOptions; track size) {
<option [ngValue]="size">{{ size || 'All' }}</option>
}
</select>
</label>
</div>
@if (pageCount > 1) {
<div class="tdsr__pager-r">
<button type="button" class="tdsr__pager-btn" (click)="goToPage(0)" [disabled]="currentPage === 0">
First
</button>
<button
type="button"
class="tdsr__pager-btn"
(click)="goToPage(currentPage - 1)"
[disabled]="currentPage === 0"
aria-label="Previous page"
>
<fa-icon icon="chevron-left"></fa-icon>
</button>
<span class="tdsr__pager-cur">Page {{ currentPage + 1 | number }} of {{ pageCount | number }}</span>
<button
type="button"
class="tdsr__pager-btn"
(click)="goToPage(currentPage + 1)"
[disabled]="currentPage >= pageCount - 1"
aria-label="Next page"
>
<fa-icon icon="chevron-right"></fa-icon>
</button>
<button
type="button"
class="tdsr__pager-btn"
(click)="goToPage(pageCount - 1)"
[disabled]="currentPage >= pageCount - 1"
>
Last
</button>
</div>
}
</div>
}
</div>
</div>
@@ -62,6 +62,30 @@ export class TdsInterestReportComponent implements OnInit {
/** Which customer's quarter-wise detail is open. */
expandedId: string | null = null;
exporting = false;
selectedTab: 'paying' | 'not-paying' = 'paying';
/** Rows per page; 0 shows the whole list. */
readonly pageSizeOptions = [
25,
50,
100,
0
];
pageSize = 25;
pageIndex = 0;
/**
* Row and summary computation is memoised against the filter, because every getter below is
* read many times per change-detection cycle (the KPI block alone reads `summary` a dozen
* times) and recomputing the whole book on each read is what made this table slow enough to
* need paging in the first place.
*/
private rowsCacheKey = '';
private allRows: TdsInterestRow[] = [];
private paying: TdsInterestRow[] = [];
private notPaying: TdsInterestRow[] = [];
private summaryCacheKey = '';
private cachedSummary: TdsInterestSummary | null = null;
ngOnInit(): void {
// A branch login sees only its own book and cannot widen the scope.
@@ -94,12 +118,107 @@ export class TdsInterestReportComponent implements OnInit {
return this.filter.branchCode ? glBranchName(this.filter.branchCode) : 'All branches';
}
/** Recomputes the book only when the filter actually changed. Side-effect free. */
private ensureRows(): void {
const key = JSON.stringify(this.filter);
if (key === this.rowsCacheKey) {
return;
}
this.rowsCacheKey = key;
this.allRows = tdsInterestRows(this.filter);
this.paying = this.allRows.filter((r) => r.schedule.totalTds > 0);
this.notPaying = this.allRows.filter((r) => r.schedule.totalTds === 0);
}
get rows(): TdsInterestRow[] {
return tdsInterestRows(this.filter);
this.ensureRows();
return this.allRows;
}
get payingRows(): TdsInterestRow[] {
this.ensureRows();
return this.paying;
}
get notPayingRows(): TdsInterestRow[] {
this.ensureRows();
return this.notPaying;
}
get summary(): TdsInterestSummary {
return tdsInterestSummary(this.filter);
const key = JSON.stringify(this.filter);
if (key !== this.summaryCacheKey || !this.cachedSummary) {
this.summaryCacheKey = key;
this.cachedSummary = tdsInterestSummary(this.filter);
}
return this.cachedSummary;
}
/** Every row of the tab on screen, before paging. */
get visibleRows(): TdsInterestRow[] {
return this.selectedTab === 'paying' ? this.payingRows : this.notPayingRows;
}
get pageCount(): number {
if (!this.pageSize) {
return 1;
}
return Math.max(1, Math.ceil(this.visibleRows.length / this.pageSize));
}
/**
* Clamped rather than assigned, so a filter that shrinks the list can never leave the table
* rendering an out-of-range page, and nothing mutates state during change detection.
*/
get currentPage(): number {
return Math.min(this.pageIndex, this.pageCount - 1);
}
/** The slice actually rendered. */
get pagedRows(): TdsInterestRow[] {
const rows = this.visibleRows;
if (!this.pageSize) {
return rows;
}
const start = this.currentPage * this.pageSize;
return rows.slice(start, start + this.pageSize);
}
get rangeStart(): number {
if (!this.visibleRows.length) {
return 0;
}
return this.pageSize ? this.currentPage * this.pageSize + 1 : 1;
}
get rangeEnd(): number {
const total = this.visibleRows.length;
return this.pageSize ? Math.min(total, (this.currentPage + 1) * this.pageSize) : total;
}
goToPage(page: number): void {
this.pageIndex = Math.max(0, Math.min(page, this.pageCount - 1));
// The open quarter-wise detail belongs to a row that is no longer on screen.
this.expandedId = null;
}
/** Keeps the first row currently on screen in view when the page size changes. */
onPageSizeChange(size: number): void {
const anchor = this.currentPage * (this.pageSize || 1);
this.pageSize = Number(size);
this.pageIndex = this.pageSize ? Math.floor(anchor / this.pageSize) : 0;
this.expandedId = null;
}
/** A different filter is a different list, so paging starts over. */
onFilterChanged(): void {
this.pageIndex = 0;
this.expandedId = null;
}
selectTab(tab: 'paying' | 'not-paying'): void {
this.selectedTab = tab;
this.onFilterChanged();
}
get hasFilters(): boolean {
@@ -121,11 +240,13 @@ export class TdsInterestReportComponent implements OnInit {
crossedOnly: false,
nearThresholdOnly: false
};
this.onFilterChanged();
}
/** Jump straight to the customers worth chasing a declaration from. */
showNearThreshold(): void {
this.filter = { ...this.filter, crossedOnly: false, nearThresholdOnly: true };
this.onFilterChanged();
}
toggleExpanded(row: TdsInterestRow): void {
+103
View File
@@ -544,3 +544,106 @@
color: var(--tdsr-blue);
}
}
/* ── Tab Switcher ── */
.tdsr__tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
border-bottom: 1px solid var(--tdsr-line);
padding-bottom: 2px;
}
.tdsr__tab {
padding: 8px 16px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--tdsr-muted);
font-size: 13.5px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: var(--tdsr-ink);
}
&--active {
color: var(--tdsr-blue);
border-bottom-color: var(--tdsr-blue);
}
}
/* ── Pager ── */
.tdsr__pager {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 10px 16px;
border-top: 1px solid var(--tdsr-line);
background: var(--tdsr-bg);
font-size: 12px;
color: var(--tdsr-muted);
strong {
color: var(--tdsr-ink);
}
}
.tdsr__pager-l,
.tdsr__pager-r {
display: flex;
align-items: center;
gap: 10px;
}
.tdsr__pager-sizel {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.tdsr__pager-size {
padding: 5px 8px;
border: 1px solid var(--tdsr-line);
border-radius: 6px;
background: #fff;
font-size: 12px;
color: var(--tdsr-ink);
}
.tdsr__pager-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 30px;
height: 28px;
padding: 0 9px;
border: 1px solid var(--tdsr-line);
border-radius: 6px;
background: #fff;
font-size: 12px;
font-weight: 700;
color: var(--tdsr-ink);
cursor: pointer;
&:hover:not(:disabled) {
background: var(--tdsr-bg);
}
&:disabled {
opacity: 0.45;
cursor: default;
}
}
.tdsr__pager-cur {
padding: 0 2px;
font-weight: 700;
color: var(--tdsr-ink);
white-space: nowrap;
}
+27 -7
View File
@@ -33,6 +33,7 @@ import { SettingsService } from './settings/settings.service';
import { DocumentationLinksService } from 'app/shared/services/documentation-links.service';
import { IdleTimeoutService } from './home/timeout-dialog/idle-timeout.service';
import { SessionTimeoutDialogComponent } from './home/timeout-dialog/session-timeout-dialog.component';
import { ErrorDialogComponent } from './shared/error-dialog/error-dialog.component';
/** Custom Items */
import { Alert } from './core/alert/alert.model';
@@ -229,6 +230,16 @@ export class WebAppComponent implements OnInit, OnDestroy {
// Setup alerts
this.alertService.alertEvent.subscribe((alertEvent: Alert) => {
if (alertEvent.severity === 'error') {
// Errors go to a centred modal instead of the corner snack bar: they need to be read and
// acknowledged, and a toast that self-dismisses after 2s was being missed entirely.
this.dialog.open(ErrorDialogComponent, {
data: { title: alertEvent.type, message: alertEvent.message },
autoFocus: true,
restoreFocus: true
});
return;
}
this.snackBar.open(`${alertEvent.message}`, 'Close', {
duration: 2000,
horizontalPosition: 'right',
@@ -264,14 +275,23 @@ export class WebAppComponent implements OnInit, OnDestroy {
});
this.idle.$onSessionTimeout.subscribe(() => {
this.alertService.alert({
type: 'Session timeout',
message: this.translateService.instant('labels.text.Session timed out')
const warningCountdown = (environment.session.timeout as any).warningCountdown || 60;
const dialogRef = this.dialog.open(SessionTimeoutDialogComponent, {
disableClose: true,
data: { countdownSeconds: warningCountdown }
});
dialogRef.afterClosed().subscribe((result) => {
if (result === 'keep-alive') {
this.idle.start();
} else {
this.alertService.alert({
type: 'Session timeout',
message: this.translateService.instant('labels.text.Session timed out')
});
this.logout();
}
});
this.dialog.open(SessionTimeoutDialogComponent);
setTimeout(() => {
this.logout();
}, 1000);
});
}
}
+1 -1
View File
@@ -54,7 +54,7 @@
window['env']['waitTimeForCOBCatchUp'] = '';
// Time in milliseconds for Session idle timeout, default 300000 seconds
window['env']['sessionIdleTimeout'] = '0';
window['env']['sessionIdleTimeout'] = '';
// OAuth Server Enabled
window['env']['oauthServerEnabled'] = false;
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260806',
'hash': 'ddb215de'
'version': '260811',
'hash': '4281a596'
},
'allow_switching_backend_instance': true
};
+2 -1
View File
@@ -74,7 +74,8 @@ export const environment = {
waitTimeForCOBCatchUp: loadedEnv['waitTimeForCOBCatchUp'] || 30,
session: {
timeout: {
idleTimeout: loadedEnv['sessionIdleTimeout'] || 300000 // 5 minutes
idleTimeout: loadedEnv['sessionIdleTimeout'] || 540000, // 9 minutes
warningCountdown: 60 // 60 seconds warning countdown
}
},
httpCacheEnabled: loadedEnv.httpCacheEnabled || false,
+2 -1
View File
@@ -76,7 +76,8 @@ export const environment = {
waitTimeForCOBCatchUp: loadedEnv.waitTimeForCOBCatchUp || 30,
session: {
timeout: {
idleTimeout: loadedEnv.sessionIdleTimeout || 300000 // 5 minutes
idleTimeout: loadedEnv.sessionIdleTimeout || 540000, // 9 minutes
warningCountdown: 60 // 60 seconds warning countdown
}
},
httpCacheEnabled: loadedEnv.httpCacheEnabled || false,