diff --git a/package.json b/package.json index 156be4d23..08890a948 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/accounting/search-journal-entry/mark-entry-dialog.component.html b/src/app/accounting/search-journal-entry/mark-entry-dialog.component.html deleted file mode 100644 index d4db3b8f8..000000000 --- a/src/app/accounting/search-journal-entry/mark-entry-dialog.component.html +++ /dev/null @@ -1,70 +0,0 @@ - - -

- - {{ isEscalation ? 'Mark as Suspicious' : 'Mark as Whitelisted' }} -

- - -
-
- #{{ data.journalEntryId }} -
-
- {{ data.transactionId }} -
-
- {{ data.transactionDate }} -
-
- ₹{{ data.amount | number: '1.2-2' }} -
-
- {{ data.glAccountName }} -
-
- - @if (isEscalation) { -

- - 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. -

- } @else { -

- Records the entry as reviewed and cleared. No report is filed and the customer's risk rating is untouched. -

- } - - -
- - - - - diff --git a/src/app/accounting/search-journal-entry/mark-entry-dialog.component.scss b/src/app/accounting/search-journal-entry/mark-entry-dialog.component.scss deleted file mode 100644 index d1b2b000f..000000000 --- a/src/app/accounting/search-journal-entry/mark-entry-dialog.component.scss +++ /dev/null @@ -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; - } - } -} diff --git a/src/app/accounting/search-journal-entry/mark-entry-dialog.component.ts b/src/app/accounting/search-journal-entry/mark-entry-dialog.component.ts deleted file mode 100644 index 207f501da..000000000 --- a/src/app/accounting/search-journal-entry/mark-entry-dialog.component.ts +++ /dev/null @@ -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); - data = inject(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()); - } - } -} diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.html b/src/app/accounting/search-journal-entry/search-journal-entry.component.html index 477b1c91c..f8dacebff 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.html +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.html @@ -535,14 +535,6 @@ - @if (markError) { -
- - {{ markError }} - -
- } -
@@ -700,50 +692,6 @@ - - - AML Review - - @if (markOf(e); as mark) { - - - {{ mark.disposition === 'ESCALATE' ? 'Suspicious' : 'Whitelisted' }} - - } @else if (isMarking(e)) { - Saving… - } @else if (canMark(e)) { - - - - - } @else { - — - } - - - diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.scss b/src/app/accounting/search-journal-entry/search-journal-entry.component.scss index d140a94ba..edc754dd0 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.scss +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.scss @@ -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; diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.ts b/src/app/accounting/search-journal-entry/search-journal-entry.component.ts index 78839ffff..39c8e1694 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.ts +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.ts @@ -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(); - /** Entry ids with a request in flight, so a row cannot be double-submitted. */ - private marking = new Set(); - 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() { diff --git a/src/app/aml/aml.service.ts b/src/app/aml/aml.service.ts index 4d4c7febb..59385a350 100644 --- a/src/app/aml/aml.service.ts +++ b/src/app/aml/aml.service.ts @@ -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 { + const params = new HttpParams() + .set('minAlerts', String(minAlerts)) + .set('windowDays', String(windowDays)) + .set('onlyNeedingReview', String(onlyNeedingReview)); + return this.http.get('/aml/recurring-alerts', { params }); + } + + /** outcome: 'CLEARED' | 'MONITOR' | 'ESCALATED' */ + reviewRecurringAlert( + clientId: number, + outcome: 'CLEARED' | 'MONITOR' | 'ESCALATED', + notes: string, + windowDays: number + ): Observable { + return this.http.post(`/aml/recurring-alerts/${clientId}/review`, { outcome, notes, windowDays }); + } + // ═══ Manual referrals from the general ledger ═══ /** diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index 5e59781f2..43fa5fedd 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -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; diff --git a/src/app/clients/client-stepper/client-general-step/sanctions-verify-dialog.component.ts b/src/app/clients/client-stepper/client-general-step/sanctions-verify-dialog.component.ts index 3a5b61b96..540dc0c20 100644 --- a/src/app/clients/client-stepper/client-general-step/sanctions-verify-dialog.component.ts +++ b/src/app/clients/client-stepper/client-general-step/sanctions-verify-dialog.component.ts @@ -38,10 +38,17 @@ import { ClientsService } from 'app/clients/clients.service'; }

- Screening {{ clientName }} against
- UN Security Council & - Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231) + Screening {{ clientName }} against the
+ UN Security Council Consolidated List
+ ISIL (Da’esh) & Al-Qaida (1267/1989/2253) and Taliban (1988) sanctions lists

+ @if (listDataAsOf) { +

+ Sanctions list data as of {{ listDataAsOf }} +

+ }

{{ regulatoryBasis }}

} @@ -54,8 +61,8 @@ import { ClientsService } from 'app/clients/clients.service';

No Sanctions Match Found

{{ clientName }} has been screened against the - UN Security Council Consolidated List and FIU-IND records. No confirmed - match was found. + UN Security Council Consolidated List (ISIL & Al-Qaida and Taliban sanctions lists). No + confirmed match was found.

@@ -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); } diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html index db335767e..aaf152d4d 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html @@ -56,6 +56,42 @@ } } + + @if (!isNonIndividual) { +
+ badge +
+ Documents are verified against the General tab +
+ + Name + {{ verificationName || 'Not entered' }} + + + Date of Birth + + {{ verificationDateOfBirth ? (verificationDateOfBirth | date: 'dd MMM yyyy') : 'Not entered' }} + + +
+ @if (!verificationName || !verificationDateOfBirth) { + + Both are required for PAN and Aadhaar verification — complete them on the General tab first. + + } @else { + + Must match the card exactly. Salutation is excluded; a mismatch in either fails verification. + + } +
+
+ } + @if (!isMinor && kycForm.get('hasPan')?.value) {
@@ -105,8 +141,109 @@ @if (panVerifyStatus === 'VERIFIED') { Verified } + @if (panVerifyStatus !== 'VERIFIED' && panDeclarationTaken) { + Declaration + }
+ + @if (panVerifyStatus === 'VERIFIED') { +
+
+
+ credit_card +
+
+
Verification Status
+
PAN ↔ Aadhaar
+
+ @if (panAadhaarSeedingStatus === 'Y') { + check_circle Linked (Seeded) + } @else if (panAadhaarSeedingStatus === 'N') { + cancel Not Linked + } @else if (panAadhaarSeedingStatus === 'E') { + info Exempt + } @else { + radio_button_unchecked Pending + } +
+
+
+
+ @if (panAadhaarSeedingStatus === 'Y') { + check + } @else if (panAadhaarSeedingStatus === 'N') { + close + } @else if (panAadhaarSeedingStatus === 'E') { + info + } @else { + schedule + } +
+
As per Income Tax Portal — Section 139AA
+
+ } + + + @if (panVerifyStatus !== 'VERIFIED' && panDeclarationTaken) { +
+
+ assignment_turned_in + PAN Declaration Taken + @if (panDeclarationTakenOn) { + {{ panDeclarationTakenOn }} + } +
+
+
+ Does not match + {{ panDeclarationMismatchLabel }} +
+ @if (panDeclaration?.nameOnPan) { +
+ Name on PAN + {{ panDeclaration?.nameOnPan }} +
+ } + @if (panDeclaration?.dobOnPan) { +
+ DOB on PAN + {{ panDeclaration?.dobOnPan | date: 'dd MMMM yyyy' }} +
+ } + @if (panDeclaration?.fileName) { +
+ Declaration file + {{ panDeclaration?.fileName }} +
+ } +
+
+ PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file. +
+ @if (!readOnly) { +
+ + +
+ } +
+ } } @@ -175,9 +312,68 @@ @if (aadhaarVerifyStatus === 'VERIFIED') { Verified } + @if (aadhaarVerifyStatus !== 'VERIFIED' && aadhaarReceiptTaken) { + Receipt + }
+ + @if (aadhaarVerifyStatus !== 'VERIFIED' && !aadhaarReceiptTaken && !aadhaarOtpSent && !readOnly) { +
+ receipt_long + Customer has applied for Aadhaar but not received it yet? + +
+ } + + + @if (aadhaarVerifyStatus !== 'VERIFIED' && aadhaarReceiptTaken) { +
+
+ receipt_long + Aadhaar Enrolment Receipt Recorded + @if (aadhaarReceiptTakenOn) { + {{ aadhaarReceiptTakenOn }} + } +
+
+
+ Enrolment ID + {{ aadhaarReceipt?.enrolmentId }} +
+ @if (aadhaarReceipt?.enrolmentDate) { +
+ Enrolment date + {{ aadhaarReceipt?.enrolmentDate | date: 'dd MMMM yyyy' }} +
+ } + @if (aadhaarReceipt?.remarks) { +
+ Remarks + {{ aadhaarReceipt?.remarks }} +
+ } +
+
+ Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file. +
+ @if (!readOnly) { +
+ + +
+ } +
+ } + @if (aadhaarOtpSent && aadhaarVerifyStatus !== 'VERIFIED') {
@@ -725,51 +921,53 @@ } -
-
- - KYC Exception Flag -
-
- Mark as KYC Exception +
+ + KYC Exception Flag +
+
+ Mark as KYC Exception - @if (kycForm.get('isException')?.value) { - - Exception Reasons - - @if (!isNonIndividual) { - PAN Card Verification Failed - Aadhaar Verification Failed - } @else { - Enterprise PAN Verification Failed - GSTIN Verification Failed - CIN Verification Failed - } - - - } + @if (kycForm.get('isException')?.value) { + + Exception Reasons + + @if (!isNonIndividual) { + PAN Card Verification Failed + Aadhaar Verification Failed + } @else { + Enterprise PAN Verification Failed + GSTIN Verification Failed + CIN Verification Failed + } + + + } +
-
+ }
@@ -847,19 +1045,23 @@ - @if (existingKycDocuments.length > 0 && !kycDocFiles.front && readOnly) { + @if (existingKycDocSides.front && !kycDocFiles.front && readOnly) {
- @if (kycDocMergedPreview) { - Saved KYC document + @if (existingKycDocSides.front?.previewUrl) { + Saved front side } @else { description }
- {{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }} + {{ existingKycDocSides.front?.fileName }} - @@ -869,8 +1071,8 @@ } @else {
- } @else if (existingKycDocuments.length > 0) { + } @else if (existingKycDocSides.front) {
- @if (kycDocMergedPreview) { - Saved KYC document + @if (existingKycDocSides.front?.previewUrl) { + Saved front side } @else { description }
- {{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }} + {{ existingKycDocSides.front?.fileName }}
+
+
+
+ } @else if (existingKycDocSides.back && !kycDocFiles.back && readOnly) { +
+
+ @if (existingKycDocSides.back?.previewUrl) { + Saved back side } @else { description }
- {{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }} + {{ existingKycDocSides.back?.fileName }} - @@ -958,8 +1184,8 @@ } @else {
- } @else if (existingKycDocuments.length > 0) { - + } @else if (existingKycDocSides.back) { +
- @if (kycDocMergedPreview) { - Saved KYC document + @if (existingKycDocSides.back?.previewUrl) { + Saved back side } @else { description }
- {{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }} + {{ existingKycDocSides.back?.fileName }}
Merged KYC document @@ -1197,27 +1429,36 @@
} - - {{ isKycVerified ? 'Reason for Re-edit *' : 'Remarks' }} - - @if (isKycVerified && !kycForm.get('remarks')?.value?.trim()) { - Remarks are mandatory when updating a verified KYC record - } - - - @if (isKycVerified) { -
- info - KYC is VERIFIED. Any update will reset the status to IN PROGRESS and - require PAN re-verification. -
+ @if (kycForm.get('allowRiskOverride')?.value || isKycVerified) { + + + {{ + isKycVerified + ? 'Reason for Re-edit' + : kycForm.get('allowRiskOverride')?.value + ? 'Remarks (Required for Risk Override)' + : 'Remarks' + }} + + + @if ((isKycVerified || kycForm.get('allowRiskOverride')?.value) && !kycForm.get('remarks')?.value?.trim()) { + + Remarks are mandatory when + {{ isKycVerified ? 'updating a verified KYC record' : 'overriding the risk category' }} + + } + }
@@ -1393,6 +1634,39 @@ Regulatory Authority + + Freeze Date + calendar_today + + + + + + Freeze Amount + currency_rupee + + Leave blank for a full freeze + + } + + @if (kycForm.get('accountStatus')?.value === 'UNFREEZE') { + + Unfreeze Date + lock_open + + + + + } + + @if (kycForm.get('accountStatus')?.value === 'NO_DEBIT') { + + No Debit From Date + money_off + + + + }
@@ -1545,4 +1819,34 @@
+ + + @if (kycForm && kycForm.invalid) { +
+ error_outline +
+ Please complete/correct the following fields to proceed: +
    + @for (label of invalidFieldLabels; track label) { +
  • {{ label }}
  • + } +
+
+
+ } diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss index 2b9231705..a9555ea7e 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss @@ -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; + } + } +} diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts index d708f7135..29d2f22f2 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts @@ -35,6 +35,26 @@ import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/c import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component'; import { dataUrlToFile } from 'app/shared/utils/data-url.util'; import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component'; +import { + PanDeclarationDialogComponent, + PanDeclarationResult +} from '../../shared/pan-declaration-dialog/pan-declaration-dialog.component'; +import { + AadhaarReceiptDialogComponent, + AadhaarReceiptResult +} from '../../shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component'; + +/** One side of the saved ID proof, as shown in its upload slot. */ +interface KycDocSide { + /** Id of the stored document this side came from. */ + id: string; + fileName: string; + previewUrl: string | null; + /** The image this slot shows — a half, when it was cut out of a stacked scan. */ + blob: Blob | null; + /** True when this side came from a single file holding both sides. */ + combined: boolean; +} @Component({ selector: 'mifosx-client-kyc-step', @@ -70,6 +90,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { @Input() constitutionMeta: any = null; /** Entity full name (non-individual) — used for enterprise PAN verification */ @Input() entityName: string = ''; + /** + * Name and date of birth captured on the General step. PAN and Aadhaar are matched against exactly + * these two values by the provider, so they are shown here: an officer should be able to see what + * the check will compare before running it, rather than discovering a mismatch only on failure. + */ + @Input() verificationName: string = ''; + @Input() verificationDateOfBirth: Date | null = null; /** Read-only mode — disables form editing until user clicks edit icon */ @Input() readOnly = false; @Input() calculatedRisk: { @@ -102,8 +129,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { selectedKycDoc: ProofType | null = null; /** Front/back files for the chosen KYC document */ kycDocFiles: { front: File | null; back: File | null } = { front: null, back: null }; - /** Combined front+back PNG (built automatically once both image sides are ready) */ - kycDocMergedFile: File | null = null; + /** + * Front and back stacked into one image, purely so the officer can eyeball both sides together. + * The sides are uploaded as separate files — this render is never saved. + */ kycDocMergedPreview: string | null = null; /** @@ -123,6 +152,25 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { previewUrl: string | null; }[] = []; + /** + * The saved proof mapped onto the two upload slots, so each side shows the side it holds. + * + * Sides are uploaded as separate files named " — Front" / " — Back", which is what + * this matches on. Clients saved before that carry one stacked "…-combined.png" holding both + * sides; that image is cut back into halves so the two slots still show a front and a back rather + * than the same picture twice. `blob` is whichever image the slot ends up showing — the half for + * a split scan, so View opens that side alone. + */ + existingKycDocSides: { + front: KycDocSide | null; + back: KycDocSide | null; + } = { front: null, back: null }; + + /** A stacked scan that could not be cut in two — the back slot has no image of its own to show. */ + get kycDocIsLegacyCombined(): boolean { + return !!this.existingKycDocSides.front?.combined && !this.existingKycDocSides.back; + } + /** Set true after a save attempt so we can highlight missing photo/signature boxes */ photoSigTouched = false; @@ -170,6 +218,26 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { .filter((key) => this.kycForm.get(key)?.invalid) .map((key) => ClientKycStepComponent.REQUIRED_FIELD_LABELS[key] || key); } + + get kycAllDone(): boolean { + if (this.isNonIndividual) { + const needsGstin = !!this.kycForm?.get('gstinNumber')?.value; + const needsCin = !!this.kycForm?.get('cinNumber')?.value; + const panOk = this.entPanVerifyStatus === 'VERIFIED'; + const gstinOk = !needsGstin || this.gstinVerifyStatus === 'VERIFIED'; + const cinOk = !needsCin || this.cinVerifyStatus === 'VERIFIED'; + return panOk && gstinOk && cinOk; + } else { + const needsPan = !this.isMinor && this.kycForm?.get('hasPan')?.value; + // A declaration settles PAN when the check cannot pass — otherwise a client whose PAN card + // spells their name differently could never be created at all. + const panOk = !needsPan || this.panVerifyStatus === 'VERIFIED' || this.panDeclarationTaken; + // Likewise for Aadhaar: a client who has only an enrolment slip has no number to verify, so + // requiring a passing OTP check would make them impossible to onboard. + const aadhaarOk = this.aadhaarVerifyStatus === 'VERIFIED' || this.aadhaarReceiptTaken; + return panOk && aadhaarOk; + } + } /** Fallback document types when template API is unavailable */ private defaultDocumentTypes = [ { name: 'Aadhaar Card', code: 'AADHAAR' }, @@ -291,6 +359,38 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { panVerifyName: string = ''; panVerifiedOn: string = ''; + /** + * Declaration 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 can never clear it however many times the officer retries. Holding a signed + * declaration that records what differs is what lets the client be created and what waives the + * PAN KYC exception — PAN itself stays unverified, which is the honest state. + */ + panDeclaration: PanDeclarationResult | null = null; + /** Date the declaration was accepted, in the tenant's display format. */ + panDeclarationTakenOn = ''; + /** Message from the failed verification — carried into the declaration dialog as its reason. */ + private panDeclarationFailureMessage = ''; + /** True when the failure was not a name/DOB mismatch (invalid PAN, vendor unreachable). */ + private panDeclarationUnverifiable = false; + + get panDeclarationTaken(): boolean { + return !!this.panDeclaration; + } + + /** "Name", "Date of Birth", "Name and Date of Birth", or the not-checked-at-all case. */ + get panDeclarationMismatchLabel(): string { + const fields = this.panDeclaration?.mismatchFields; + if (!fields || fields === 'NONE') { + return 'PAN could not be verified'; + } + return fields + .split(',') + .map((f) => (f === 'NAME' ? 'Name' : 'Date of Birth')) + .join(' and '); + } + /** Aadhaar verification */ aadhaarVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE'; aadhaarVerifying = false; @@ -302,6 +402,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { aadhaarOtpVerifying = false; aadhaarOtpReferenceId = ''; + /** + * 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. With no Aadhaar number there is nothing to send to the OTP flow, so the check can never + * pass — recording the slip is what lets the client be created and waives the Aadhaar exception. + */ + aadhaarReceipt: AadhaarReceiptResult | null = null; + /** Date the receipt was accepted, in the tenant's display format. */ + aadhaarReceiptTakenOn = ''; + + get aadhaarReceiptTaken(): boolean { + return !!this.aadhaarReceipt; + } + /** OTP digit boxes */ otpDigits: string[] = [ '', @@ -494,6 +609,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { accountStatus: ['ACTIVE'], freezeReason: [''], freezeAuthority: [''], + freezeDate: [''], + freezeAmount: [''], + unfreezeDate: [''], + noDebitDate: [''], form60Type: ['FORM_60'], form60Date: [''], form15Applicable: [false], @@ -548,12 +667,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { if (!this.readOnly) { riskControl?.enable(); } + this.kycForm.get('remarks')?.setValidators(Validators.required); } else { riskControl?.disable(); if (this.calculatedRisk) { riskControl?.setValue(this.calculatedRisk.category); } + if (!this.isKycVerified) { + this.kycForm.get('remarks')?.clearValidators(); + } } + this.kycForm.get('remarks')?.updateValueAndValidity(); }); this.kycForm.get('allowAmlOverride')?.valueChanges.subscribe((allowed: boolean) => { @@ -636,22 +760,28 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { this.kycDocumentTypes = []; this.selectedKycDoc = null; this.kycDocFiles = { front: null, back: null }; - this.kycDocMergedFile = null; this.kycDocMergedPreview = null; this.existingKycIdentifierId = null; this.riskCalculationReason = ''; this.showRiskRulesTable = false; this.existingKycDocuments = []; + this.existingKycDocSides = { front: null, back: null }; this.photoSigTouched = false; this.panVerifyStatus = 'NONE'; this.panVerifying = false; this.panVerifyError = ''; this.panVerifyName = ''; this.panVerifiedOn = ''; + this.panDeclaration = null; + this.panDeclarationTakenOn = ''; + this.panDeclarationFailureMessage = ''; + this.panDeclarationUnverifiable = false; this.aadhaarVerifyStatus = 'NONE'; this.aadhaarVerifying = false; this.aadhaarVerifyError = ''; this.aadhaarVerifyName = ''; + this.aadhaarReceipt = null; + this.aadhaarReceiptTakenOn = ''; this.aadhaarVisible = false; this.aadhaarOtpSent = false; this.resetOtpDigits(); @@ -788,7 +918,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { this.kycDocGroups = groupProofsByCategory(this.kycDocProofs); // Sync selected proof with current form value (e.g. on edit-mode prefill) const currentName = this.kycForm?.get('kycDocumentName')?.value; - this.selectedKycDoc = currentName ? this.kycDocProofs.find((p) => p.name === currentName) || null : null; + if (currentName) { + const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); + const targetName = cleanStr(currentName); + this.selectedKycDoc = this.kycDocProofs.find((p) => cleanStr(p.name) === targetName) || null; + } else { + this.selectedKycDoc = null; + } } selectKycDoc(proof: ProofType): void { @@ -796,7 +932,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { this.kycForm.get('kycDocumentName')?.setValue(proof.name); // Reset uploads + merged preview when document type changes this.kycDocFiles = { front: null, back: null }; - this.kycDocMergedFile = null; this.kycDocMergedPreview = null; } @@ -828,31 +963,40 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { removeKycDocFile(slot: 'front' | 'back'): void { this.kycDocFiles[slot] = null; - this.kycDocMergedFile = null; this.kycDocMergedPreview = null; } - /** Open the saved KYC ID-proof document in an in-app preview dialog. */ - viewExistingKycDoc(): void { - if (!this.existingKycIdentifierId || this.existingKycDocuments.length === 0) { + /** Open the saved file for one side of the KYC ID proof in an in-app preview dialog. */ + viewExistingKycDoc(slot: 'front' | 'back' = 'front'): void { + const side = this.existingKycDocSides[slot]; + if (!side) { return; } - const doc = this.existingKycDocuments[0]; - this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId, doc.id).subscribe({ - next: (blob: Blob) => - this.dialog.open(DocumentPreviewDialogComponent, { - data: { blob, title: doc.fileName || doc.name, fileName: doc.fileName || doc.name } - }), + const show = (blob: Blob) => + this.dialog.open(DocumentPreviewDialogComponent, { + data: { blob, title: side.fileName, fileName: side.fileName } + }); + // A side cut out of a stacked scan is only held here, so show that half rather than + // re-downloading the file with both sides on it. + if (side.blob) { + show(side.blob); + return; + } + if (!this.existingKycIdentifierId) { + return; + } + this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId, side.id).subscribe({ + next: show, error: () => { /* silent — UI still shows the saved filename */ } }); } + /** Builds the both-sides-at-once preview. The merged image is shown, never uploaded. */ private async refreshKycDocMerged(): Promise { const proof = this.selectedKycDoc; if (!proof || proof.sides !== 2 || !this.kycDocFiles.front || !this.kycDocFiles.back) { - this.kycDocMergedFile = null; this.kycDocMergedPreview = null; return; } @@ -861,19 +1005,15 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { this.kycDocFiles.back, proof.name || 'kyc-document' ); - if (merged) { - this.kycDocMergedFile = merged.file; - this.kycDocMergedPreview = merged.preview; - } else { - this.kycDocMergedFile = null; - this.kycDocMergedPreview = null; - } + this.kycDocMergedPreview = merged ? merged.preview : null; } /** * Files + proof metadata for the parent to persist as a client identifier. - * When both image sides are present they're sent as a single combined PNG; - * otherwise the individual files are returned. + * + * Each side is sent as its own file, named by slot. They used to be stacked into a single + * combined PNG, which left one image standing for two slots — so re-opening the client showed + * the same front-and-back picture under both "Front side" and "Back side". */ get kycDocPayload(): { proof: ProofType | null; @@ -882,13 +1022,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { files: { slot: string; file: File }[]; } { const files: { slot: string; file: File }[] = []; - if (this.selectedKycDoc?.sides === 2 && this.kycDocMergedFile) { - files.push({ slot: 'Combined', file: this.kycDocMergedFile }); - } else { - if (this.kycDocFiles.front) files.push({ slot: 'Front', file: this.kycDocFiles.front }); - if (this.selectedKycDoc?.sides === 2 && this.kycDocFiles.back) { - files.push({ slot: 'Back', file: this.kycDocFiles.back }); - } + if (this.kycDocFiles.front) files.push({ slot: 'Front', file: this.kycDocFiles.front }); + if (this.selectedKycDoc?.sides === 2 && this.kycDocFiles.back) { + files.push({ slot: 'Back', file: this.kycDocFiles.back }); } return { proof: this.selectedKycDoc, @@ -953,6 +1089,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { accountStatus: data.accountStatus || 'ACTIVE', freezeReason: data.freezeReason || '', freezeAuthority: data.freezeAuthority || '', + freezeDate: parseDate(data.freezeDate), + freezeAmount: data.freezeAmount ?? '', + unfreezeDate: parseDate(data.unfreezeDate), + noDebitDate: parseDate(data.noDebitDate), // Form 60 / Form 15 form60Type: data.form60Type || 'FORM_60', form60Date: parseDate(data.form60Date), @@ -972,11 +1112,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { this.persistedExceptionReason = data.exceptionReason || null; // Sync chip-picker selection with the prefilled document name if (data.kycDocumentName) { - this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null; + const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); + const targetName = cleanStr(data.kycDocumentName); + this.selectedKycDoc = this.kycDocProofs.find((p) => cleanStr(p.name) === targetName) || null; } // Set verification statuses from existing data if (data.panVerified) { - this.panVerifyStatus = 'VERIFIED'; + if (this.isNonIndividual) { + this.entPanVerifyStatus = 'VERIFIED'; + } else { + this.panVerifyStatus = 'VERIFIED'; + } this.panVerifyName = data.panFullName || data.panHolderName || ''; const rawDate = data.panVerifiedOn; if (Array.isArray(rawDate) && rawDate.length >= 3) { @@ -985,9 +1131,33 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { this.panVerifiedOn = rawDate; } } + // A PAN that verified needs no declaration; one that did not may already carry one, and + // the officer has to see it — it is the only reason PAN shows no exception. + if (!data.panVerified && data.panDeclarationTaken === true) { + this.panVerifyStatus = 'FAILED'; + this.panDeclaration = { + mismatchFields: data.panDeclarationMismatchFields || 'NONE', + nameOnPan: data.panDeclarationNameOnPan || null, + dobOnPan: this.parseApiDate(data.panDeclarationDobOnPan), + // The scan itself lives with the client's documents; the record keeps its name. + file: null, + fileName: data.panDeclarationRemarks || '' + }; + this.panDeclarationTakenOn = this.formatApiDate(data.panDeclarationTakenOn); + this.panDeclarationUnverifiable = this.panDeclaration.mismatchFields === 'NONE'; + } if (data.aadhaarVerified) { this.aadhaarVerifyStatus = 'VERIFIED'; this.aadhaarVerifyName = data.aadhaarHolderName || 'Aadhaar Verified'; + } else if (data.aadhaarReceiptTaken === true) { + // Aadhaar is unverified but an enrolment slip is on file — the officer has to see it, + // since it is the only reason Aadhaar carries no exception. + this.aadhaarReceipt = { + enrolmentId: data.aadhaarEnrolmentId || '', + enrolmentDate: this.parseApiDate(data.aadhaarEnrolmentDate), + remarks: data.aadhaarReceiptRemarks || '' + }; + this.aadhaarReceiptTakenOn = this.formatApiDate(data.aadhaarReceiptTakenOn); } if (data.panAadhaarSeedingStatus) { this.panAadhaarSeedingStatus = data.panAadhaarSeedingStatus; @@ -1003,6 +1173,15 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { this.gstinVerifiedOn = rawGstinDate; } } + if (data.cinVerified) { + this.cinVerifyStatus = 'VERIFIED'; + const rawCinDate = data.cinVerifiedOn; + if (Array.isArray(rawCinDate) && rawCinDate.length >= 3) { + this.cinVerifiedOn = `${rawCinDate[2]?.toString().padStart(2, '0')}/${rawCinDate[1]?.toString().padStart(2, '0')}/${rawCinDate[0]}`; + } else if (rawCinDate) { + this.cinVerifiedOn = rawCinDate; + } + } } // Load existing photo & signature this.loadExistingPhoto(); @@ -1032,9 +1211,16 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { this.clientsService.getClientIdentifiers(this.clientId).subscribe({ next: (identifiers: any[]) => { if (!identifiers || identifiers.length === 0) return; + const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); const isKycType = (i: any) => { - const n = i?.documentType?.name?.toLowerCase(); - return !!n && this.kycDocProofs.some((p) => p.name?.toLowerCase() === n); + const n = cleanStr(i?.documentType?.name); + return ( + !!n && + this.kycDocProofs.some((p) => { + const pname = cleanStr(p.name); + return pname === n || n.includes(pname) || pname.includes(n); + }) + ); }; // Re-uploading a KYC document creates a new identifier row rather than updating the // old one in place (same pattern as OVD identifiers), so sort newest-first before @@ -1044,7 +1230,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { // Pick by document-type name match first; otherwise the first identifier whose // type lives in the KYC proof list (avoid bleeding the address proof in here). const match = kycDocumentName - ? sorted.find((i: any) => i?.documentType?.name?.toLowerCase() === kycDocumentName.toLowerCase()) + ? sorted.find((i: any) => { + const name = cleanStr(i?.documentType?.name); + const kName = cleanStr(kycDocumentName); + return name === kName || name.includes(kName) || kName.includes(name); + }) : null; const identifier = match || sorted.find(isKycType); if (!identifier?.id) return; @@ -1071,30 +1261,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { size: d.size || 0, previewUrl: null as string | null })); - // Build a preview for the first image-typed document so the UI can show - // exactly what the user uploaded last time. - const firstImage = this.existingKycDocuments.find((d) => (d.type || '').toLowerCase().startsWith('image/')); - if (firstImage) { - this.clientsService - .downloadClientIdentificationDocument(this.existingKycIdentifierId!, firstImage.id) - .subscribe({ - next: async (blob: any) => { - const buffer = await blob.arrayBuffer(); - const bytes = new Uint8Array(buffer); - let binary = ''; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } - firstImage.previewUrl = 'data:' + blob.type + ';base64,' + btoa(binary); - // Mirror onto the merged-preview slot so the existing chip-picker - // visualisation lights up without template changes. - if (!this.kycDocMergedPreview) { - this.kycDocMergedPreview = firstImage.previewUrl; - } - }, - error: () => {} - }); - } + this.mapExistingKycDocsToSides(); }, error: () => {} }); @@ -1103,6 +1270,137 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { }); } + /** + * Works out which saved file belongs to which side, then fetches a thumbnail for each. + * + * Uploads are named " — Front" / " — Back", so the side is read off the document + * name and only falls back to upload order when the name says nothing. A file that announces + * itself as combined is handed to the splitter, since it holds both sides in one image. + */ + private mapExistingKycDocsToSides(): void { + const sideOf = (doc: { name: string; fileName: string }): 'front' | 'back' | 'combined' | null => { + const text = `${doc.name || ''} ${doc.fileName || ''}`.toLowerCase(); + if (text.includes('combined')) return 'combined'; + if (text.includes('back')) return 'back'; + if (text.includes('front')) return 'front'; + return null; + }; + + const entry = (doc: { id: string; name: string; fileName: string }, combined: boolean): KycDocSide => ({ + id: doc.id, + fileName: doc.fileName || doc.name, + previewUrl: null, + blob: null, + combined + }); + + const sides: { front: KycDocSide | null; back: KycDocSide | null } = { front: null, back: null }; + const unlabelled: typeof this.existingKycDocuments = []; + + for (const doc of this.existingKycDocuments) { + const side = sideOf(doc); + if (side === 'combined') { + sides.front = sides.front || entry(doc, true); + } else if (side === 'front') { + sides.front = entry(doc, false); + } else if (side === 'back') { + sides.back = entry(doc, false); + } else { + unlabelled.push(doc); + } + } + + // Nothing in the names to go on — fall back to upload order, front first. + for (const doc of unlabelled) { + if (!sides.front) { + sides.front = entry(doc, false); + } else if (!sides.back) { + sides.back = entry(doc, false); + } + } + + this.existingKycDocSides = sides; + + if (sides.front?.combined && !sides.back) { + this.splitCombinedKycDoc(sides.front); + return; + } + + for (const key of [ + 'front', + 'back' + ] as const) { + const side = sides[key]; + if (side && this.isImageKycDoc(side.id)) { + this.loadKycSidePreview(side); + } + } + } + + private isImageKycDoc(id: string): boolean { + const doc = this.existingKycDocuments.find((d) => d.id === id); + if (!doc) { + return false; + } + const mime = (doc.type || '').toLowerCase(); + const name = (doc.fileName || '').toLowerCase(); + return mime.startsWith('image/') || name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png'); + } + + private loadKycSidePreview(side: KycDocSide): void { + this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId!, side.id).subscribe({ + next: (blob: Blob) => { + side.blob = blob; + side.previewUrl = URL.createObjectURL(blob); + const doc = this.existingKycDocuments.find((d) => d.id === side.id); + if (doc) { + doc.previewUrl = side.previewUrl; + } + }, + error: () => {} + }); + } + + /** + * Cuts a saved front-above-back scan back into the two sides it was made from. + * + * Clients saved before the sides were stored separately have one stacked image and no back file, + * which is why both slots used to show the same picture. The seam left by the merge is what makes + * the split possible; when it cannot be found the front slot keeps the whole scan and the back + * slot says where its side is, rather than repeating the image and implying a back scan exists. + */ + private splitCombinedKycDoc(front: KycDocSide): void { + if (!this.isImageKycDoc(front.id)) { + return; + } + const base = front.fileName; + this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId!, front.id).subscribe({ + next: (blob: Blob) => { + this.kycUpload.splitFrontBack(blob).then((split) => { + if (!split) { + front.blob = blob; + front.previewUrl = URL.createObjectURL(blob); + return; + } + front.fileName = `${base} (front)`; + front.blob = split.front; + front.previewUrl = URL.createObjectURL(split.front); + this.existingKycDocSides = { + front, + back: { + id: front.id, + fileName: `${base} (back)`, + previewUrl: URL.createObjectURL(split.back), + blob: split.back, + combined: true + } + }; + }); + }, + error: () => {} + }); + } + /** Fetch existing client profile photo */ private loadExistingPhoto(): void { if (!this.clientId) return; @@ -1213,6 +1511,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { ) }); } else { + if (this.kycForm.get('allowRiskOverride')?.value && !this.kycForm.get('remarks')?.value?.trim()) { + onError('Remarks are mandatory when overriding the risk category.'); + this.kycForm.get('remarks')?.markAsTouched(); + return; + } // Normal update for non-verified records this.clientsService.updateClientKyc(this.clientId, this.kycId, updatePayload).subscribe({ next: () => callback(), @@ -1220,6 +1523,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { }); } } else { + if (this.kycForm.get('allowRiskOverride')?.value && !this.kycForm.get('remarks')?.value?.trim()) { + onError('Remarks are mandatory when overriding the risk category.'); + this.kycForm.get('remarks')?.markAsTouched(); + return; + } // First time — use POST to create this.clientsService.saveClientKyc(this.clientId, payload).subscribe({ next: (res: any) => { @@ -1291,13 +1599,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { // If name is empty, the client name/DOB didn't match PAN holder if (!resolvedName || !resolvedName.trim()) { this.panVerifyStatus = 'FAILED'; - this.showPanErrorAlert( - "PAN verification failed: Client name or date of birth does not match the PAN card holder. Please ensure the client's name and date of birth exactly match the PAN card details." + this.handlePanFailure( + "Client name or date of birth does not match the PAN card holder. Please ensure the client's name and date of birth exactly match the PAN card details.", + false ); return; } this.panVerifyStatus = 'VERIFIED'; + // A PAN that now verifies makes any earlier declaration moot — drop it so the record + // does not keep claiming a mismatch that no longer exists. + this.clearPanDeclaration(); this.panVerifyName = resolvedName; // Date — could be string or array [y,m,d] const rawDate = res.changes?.panVerifiedOn; @@ -1315,7 +1627,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { } } else { this.panVerifyStatus = 'FAILED'; - this.showPanErrorAlert(res?.changes?.panVerificationMessage || 'PAN verification failed.'); + this.handlePanFailure(res?.changes?.panVerificationMessage || 'PAN verification failed.', true); } }, error: (err: any) => { @@ -1325,7 +1637,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'PAN verification failed.'; - this.showPanErrorAlert(msg); + // The backend reports a name/DOB mismatch through a dedicated error, so a message + // naming it is treated as the comparable case even though it arrives on the error path. + this.handlePanFailure(msg, !this.isNameOrDobMismatchMessage(msg)); } }); }, @@ -1336,6 +1650,165 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { ); } + /** Does this failure message describe a name/DOB mismatch, as opposed to any other failure? */ + private isNameOrDobMismatchMessage(message: string): boolean { + const lower = (message || '').toLowerCase(); + return lower.includes('does not match') || lower.includes('name.mismatch') || lower.includes('name mismatch'); + } + + /** + * Shows why PAN verification failed and offers the declaration route out of it. + * + * Every PAN failure is offered a declaration, not only the mismatch case: an officer facing an + * unreachable vendor is just as stuck as one facing a misspelt PAN card, and both are resolved by + * the same signed paperwork. `unverifiable` only changes what the dialog asks for — there is no + * point comparing against a PAN card when the check never reached the Income Tax records. + */ + private handlePanFailure(message: string, unverifiable: boolean): void { + this.panDeclarationFailureMessage = message; + this.panDeclarationUnverifiable = unverifiable; + this.panVerifyError = ''; + this.dialog + .open(PanErrorDialogComponent, { + width: '480px', + data: { message, offerDeclaration: true } + }) + .afterClosed() + .subscribe((takeDeclaration: boolean) => { + if (takeDeclaration) { + this.openPanDeclaration(); + } + }); + } + + /** + * Opens the declaration dialog and, on save, persists it with the KYC record. + * + * The declaration is written straight through rather than held until the next Save & Next: it is + * what suppresses the PAN exception, and an officer who takes a declaration and then navigates + * away must not find the client sitting in the exception list. + */ + openPanDeclaration(): void { + if (!this.clientId) { + this.showPanErrorAlert('Client must be created first. Complete General tab and click Next.'); + return; + } + const dateFormat = this.settingsService.dateFormat; + this.dialog + .open(PanDeclarationDialogComponent, { + width: '720px', + maxWidth: '95vw', + disableClose: true, + data: { + submittedName: this.verificationName, + submittedDob: this.verificationDateOfBirth + ? this.dateUtils.formatDate(this.verificationDateOfBirth, dateFormat) + : '', + panNumber: (this.kycForm.value.panNumber || '').toUpperCase().trim(), + failureMessage: this.panDeclarationFailureMessage || 'PAN verification failed.', + unverifiable: this.panDeclarationUnverifiable, + existing: this.panDeclaration + } + }) + .afterClosed() + .subscribe((result: PanDeclarationResult | undefined) => { + if (!result) { + return; + } + // The scan has to reach the client's documents before the KYC record claims a declaration + // is on file — otherwise the exception is waived by a document nobody can produce. + if (result.file) { + this.uploadPanDeclarationFile( + result.file, + (msg) => this.showPanErrorAlert(msg), + () => this.storePanDeclaration(result, dateFormat) + ); + } else { + this.storePanDeclaration(result, dateFormat); + } + }); + } + + /** Files the signed declaration against the client, so the waiver has its proof attached. */ + private uploadPanDeclarationFile(file: File, onError: (msg: string) => void, onDone: () => void): void { + const formData = new FormData(); + formData.append('file', file); + formData.append('name', 'PAN Declaration'); + formData.append( + 'description', + `Signed PAN declaration for ${(this.kycForm.value.panNumber || '').toUpperCase().trim() || 'PAN'}` + ); + this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({ + next: () => onDone(), + error: (err: any) => + onError( + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + 'Could not upload the signed declaration. Please try again.' + ) + }); + } + + private storePanDeclaration(result: PanDeclarationResult, dateFormat: string): void { + this.panDeclaration = result; + this.panDeclarationTakenOn = this.dateUtils.formatDate(new Date(), dateFormat); + this.saveKycAndDo( + this.kycData, + () => { + /* stored — the KYC record now carries the declaration and no PAN exception */ + }, + (msg) => { + // Roll the declaration back so the UI never shows one the server did not accept. + this.clearPanDeclaration(); + this.showPanErrorAlert(msg); + } + ); + } + + /** + * Fineract serialises dates as `[yyyy, M, d]`, but the same field comes back as a formatted + * string from the command-response path — both shapes have to be accepted. + */ + 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; + } + + /** The same value rendered for display in the tenant's date format. */ + private formatApiDate(raw: any): string { + const parsed = this.parseApiDate(raw); + return parsed ? this.dateUtils.formatDate(parsed, this.settingsService.dateFormat) : ''; + } + + /** Drops the declaration, both in the UI and from the next payload written to the server. */ + private clearPanDeclaration(): void { + this.panDeclaration = null; + this.panDeclarationTakenOn = ''; + } + + /** Officer-initiated removal — e.g. the declaration was taken against the wrong PAN. */ + removePanDeclaration(): void { + if (!this.panDeclaration) { + return; + } + this.clearPanDeclaration(); + if (this.clientId && this.kycId) { + this.saveKycAndDo( + this.kycData, + () => { + /* removed — the PAN exception is raised again on this same save */ + }, + (msg) => this.showPanErrorAlert(msg) + ); + } + } + /** * Verify Enterprise PAN (non-individual) — same flow as individual PAN verification. * Maps: Company PAN → panNumber, Company Name → client name, Date of Incorporation → DOB @@ -1631,6 +2104,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { this.aadhaarVerifyStatus = 'VERIFIED'; this.aadhaarVerifyName = res.changes?.aadhaarHolderName || 'Aadhaar Verified'; this.aadhaarOtpSent = false; + // 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. + this.clearAadhaarReceipt(); if (res.changes?.panAadhaarSeedingStatus) { this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus; } @@ -1645,6 +2121,79 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { }); } + /** + * Opens the enrolment-receipt dialog and, on save, persists it with the KYC record. + * + * Written straight through rather than held until the next Save & Next: the receipt is what + * suppresses the Aadhaar exception, and an officer who records one and then navigates away must + * not find the client sitting in the exception list. + */ + openAadhaarReceipt(): void { + if (!this.clientId) { + this.aadhaarVerifyError = 'Client must be created first. Complete General tab and click Next.'; + return; + } + const dateFormat = this.settingsService.dateFormat; + this.dialog + .open(AadhaarReceiptDialogComponent, { + width: '560px', + maxWidth: '95vw', + disableClose: true, + data: { + clientName: this.verificationName, + reason: + this.aadhaarVerifyError || + 'Aadhaar has not been verified. Record the enrolment slip if the customer has applied but not yet received their Aadhaar.', + existing: this.aadhaarReceipt + } + }) + .afterClosed() + .subscribe((result: AadhaarReceiptResult | undefined) => { + if (!result) { + return; + } + this.aadhaarReceipt = result; + this.aadhaarReceiptTakenOn = this.dateUtils.formatDate(new Date(), dateFormat); + this.aadhaarVerifyError = ''; + this.saveKycAndDo( + this.kycData, + () => { + /* stored — the KYC record now carries the receipt and no Aadhaar exception */ + }, + (msg) => { + // Roll back so the UI never shows a receipt the server did not accept. + this.clearAadhaarReceipt(); + this.aadhaarVerifyError = msg; + } + ); + }); + } + + /** Drops the receipt, both in the UI and from the next payload written to the server. */ + private clearAadhaarReceipt(): void { + this.aadhaarReceipt = null; + this.aadhaarReceiptTakenOn = ''; + } + + /** Officer-initiated removal — the Aadhaar exception is raised again on this same save. */ + removeAadhaarReceipt(): void { + if (!this.aadhaarReceipt) { + return; + } + this.clearAadhaarReceipt(); + if (this.clientId && this.kycId) { + this.saveKycAndDo( + this.kycData, + () => { + /* removed */ + }, + (msg) => { + this.aadhaarVerifyError = msg; + } + ); + } + } + private showPanErrorAlert(message: string) { this.panVerifyError = ''; this.dialog.open(PanErrorDialogComponent, { @@ -1844,6 +2393,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null, aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked, panAadhaarSeedingStatus: this.panAadhaarSeedingStatus || null, + // PAN declaration — stored so the waived exception can be justified later, and so a reload + // does not silently drop the paperwork that is holding the exception off. + panDeclarationTaken: this.panDeclarationTaken, + panDeclarationMismatchFields: this.panDeclaration?.mismatchFields || null, + panDeclarationNameOnPan: this.panDeclaration?.nameOnPan || null, + panDeclarationDobOnPan: formatDate(this.panDeclaration?.dobOnPan || null), + // Names the uploaded declaration filed under the client's documents. + panDeclarationRemarks: this.panDeclaration?.fileName || null, + panDeclarationTakenOn: this.panDeclarationTakenOn || null, + // Aadhaar enrolment receipt — same reasoning as the PAN declaration above. + aadhaarReceiptTaken: this.aadhaarReceiptTaken, + aadhaarEnrolmentId: this.aadhaarReceipt?.enrolmentId || null, + aadhaarEnrolmentDate: formatDate(this.aadhaarReceipt?.enrolmentDate || null), + aadhaarReceiptRemarks: this.aadhaarReceipt?.remarks || null, + aadhaarReceiptTakenOn: this.aadhaarReceiptTakenOn || null, gstinNumber: gstinNorm, enterprisePan: enterprisePanNorm, cinNumber: cinNorm, @@ -1863,6 +2427,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { accountStatus: v.accountStatus || null, freezeReason: v.freezeReason || null, freezeAuthority: v.freezeAuthority || null, + freezeDate: formatDate(v.freezeDate), + freezeAmount: + v.freezeAmount === '' || v.freezeAmount === null || v.freezeAmount === undefined ? null : v.freezeAmount, + unfreezeDate: formatDate(v.unfreezeDate), + noDebitDate: formatDate(v.noDebitDate), // Form 60 / Form 15 form60Type: v.form60Type || null, form60Date: formatDate(v.form60Date), @@ -1877,7 +2446,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { panVerified: this.isNonIndividual ? this.entPanVerifyStatus === 'VERIFIED' : this.panVerifyStatus === 'VERIFIED', + // A signed declaration closes the PAN item by paperwork when the API never can. + panDeclarationTaken: this.panDeclarationTaken, aadhaarVerified: this.aadhaarVerifyStatus === 'VERIFIED', + // An enrolment slip closes the Aadhaar item when there is no number to verify yet. + aadhaarReceiptTaken: this.aadhaarReceiptTaken, // Non-individual clients submit a dummy masked Aadhaar, so checking it would saddle every // company with a permanent exception. aadhaarApplicable: !this.isNonIndividual, @@ -1932,9 +2505,20 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {

{{ data.message }}

+ @if (data.offerDeclaration) { +

+ If the customer's PAN card genuinely carries different details, take a declaration from them instead. The + declaration records what does not match and lets the client be created without a PAN KYC exception. +

+ }
- + @if (data.offerDeclaration) { + + + } @else { + + } `, @@ -1963,9 +2547,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { line-height: 1.6; color: #475569; } + .pan-err-hint { + margin: 12px 0 0; + padding-top: 12px; + border-top: 1px solid #e2e8f0; + font-size: 13px; + color: #64748b; + } .pan-err-actions { padding: 10px 24px 16px !important; margin: 0 !important; + gap: 8px; } ` ], @@ -1978,5 +2570,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { ] }) export class PanErrorDialogComponent { - data = inject<{ message: string }>(MAT_DIALOG_DATA); + /** + * `offerDeclaration` turns the dead-end error into a fork: retry, or record the customer + * declaration that stands in for a check the client can never pass. Closes with `true` when the + * officer chooses the declaration. + */ + data = inject<{ message: string; offerDeclaration?: boolean }>(MAT_DIALOG_DATA); } diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html index 80a1e1c13..d379b4baf 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html @@ -68,6 +68,32 @@ } + + @if (showKycDocumentAlert) { +
+ {{ + kycDocumentAlertSeverity === 'critical' ? 'error' : 'schedule' + }} +
+ {{ kycDocumentAlertTitle }} +

{{ kycDocumentAlertMessage }}

+
+ +
+ } +
@@ -87,12 +113,21 @@ @if (kycData.panVerified) { check_circle Verified + } @else if (panDeclarationTaken) { + + assignment_turned_in Declaration } @else { radio_button_unchecked Pending } {{ - kycData.panVerifiedOn ? formatDate(kycData.panVerifiedOn) : '—' + kycData.panVerified + ? kycData.panVerifiedOn + ? formatDate(kycData.panVerifiedOn) + : '—' + : panDeclarationTaken && kycData.panDeclarationTakenOn + ? formatDate(kycData.panDeclarationTakenOn) + : '—' }} + }
+ + @if (!kycData.panVerified && panDeclarationTaken) { +
+
+ assignment_turned_in + PAN Declaration + @if (!saving) { + + + + + } +
+
+
+ Does not match + {{ panDeclarationMismatchLabel }} +
+ @if (kycData.panDeclarationNameOnPan) { +
+ Name on PAN + {{ kycData.panDeclarationNameOnPan }} +
+ } + @if (kycData.panDeclarationDobOnPan) { +
+ DOB on PAN + {{ formatDate(kycData.panDeclarationDobOnPan) }} +
+ } + @if (kycData.panDeclarationRemarks) { +
+ Declaration file + {{ kycData.panDeclarationRemarks }} +
+ } +
+
+ PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file. +
+
+ } +
@@ -120,12 +226,21 @@ @if (kycData.aadhaarVerified) { check_circle Verified + } @else if (aadhaarReceiptTaken) { + + receipt_long Receipt } @else { radio_button_unchecked Pending } {{ - kycData.aadhaarVerifiedOn ? formatDate(kycData.aadhaarVerifiedOn) : '—' + kycData.aadhaarVerified + ? kycData.aadhaarVerifiedOn + ? formatDate(kycData.aadhaarVerifiedOn) + : '—' + : aadhaarReceiptTaken && kycData.aadhaarReceiptTakenOn + ? formatDate(kycData.aadhaarReceiptTakenOn) + : '—' }} @if (!showAadhaarReVerify && !aadhaarOtpSent) { @@ -139,9 +254,142 @@ Re-Verify } + + @if (!kycData.aadhaarVerified && !aadhaarReceiptTaken) { + + }
+ + @if (!kycData.aadhaarVerified && aadhaarReceiptTaken) { +
+
+ receipt_long + Aadhaar Enrolment Receipt + @if (!saving) { + + + + + } +
+
+
+ Enrolment ID + {{ kycData.aadhaarEnrolmentId || '—' }} +
+ @if (kycData.aadhaarEnrolmentDate) { +
+ Enrolment date + {{ formatDate(kycData.aadhaarEnrolmentDate) }} +
+ } + @if (kycData.aadhaarReceiptRemarks) { +
+ Remarks + {{ kycData.aadhaarReceiptRemarks }} +
+ } +
+
+ Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file. +
+
+ } + + + @if (kycData.kycDocumentName) { +
+ + description {{ kycData.kycDocumentName }} + + {{ kycData.kycDocumentNumber || '—' }} + + @switch (kycDocumentRenewal.state) { + @case ('EXPIRED') { + error Expired + } + @case ('EXPIRING_SOON') { + schedule Expires Soon + } + @case ('DUE') { + update Update Due + } + @case ('OK') { + check_circle Current + } + @default { + help_outline No dates + } + } + + + + @if (kycData.kycDocIssueDate || kycData.kycDocExpiryDate) { + + @if (kycData.kycDocIssueDate) { + Issued {{ formatDate(kycData.kycDocIssueDate) }} + } + @if (kycDocumentExpiresOn) { + + Expires {{ kycDocumentExpiresOn }} + @if (kycDocumentExpiryIsDerived) { + 5-yr + } + + } + + } @else { + — + } + + + + +
+ } + @if (isNonIndividual) {
@@ -360,39 +608,72 @@
- KYC Type - {{ - kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—' - }} + assignment_ind +
+ KYC Type + + {{ kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—' }} + +
+
- Risk Category - {{ - kycData.riskCategory === 1 - ? 'Low' - : kycData.riskCategory === 2 - ? 'Medium' - : kycData.riskCategory === 3 - ? 'High' - : '—' - }} + shield +
+ Risk Category + + {{ + kycData.riskCategory === 1 + ? 'Low' + : kycData.riskCategory === 2 + ? 'Medium' + : kycData.riskCategory === 3 + ? 'High' + : '—' + }} + +
+
- Initiated On - {{ formatDate(kycData.initiatedOn) }} + calendar_today +
+ Initiated On + {{ formatDate(kycData.initiatedOn) }} +
+
- Last Verified - {{ formatDate(kycData.verifiedOn) }} + check_circle +
+ Last Verified + {{ formatDate(kycData.verifiedOn) }} +
+
- Expiry Date - {{ formatDate(kycData.expiryDate) }} + history_toggle_off +
+ Expiry Date + {{ formatDate(kycData.expiryDate) }} +
+ @if (kycData.remarks) {
- Remarks - {{ kycData.remarks }} + notes +
+ Remarks + {{ kycData.remarks }} +
}
@@ -567,6 +848,22 @@ error_outline {{ verificationError.pan }}
+ +
+ Customer's PAN card genuinely carries different details, or PAN cannot be checked? + +
} @if (verificationStatus.pan === 'VERIFIED' && verificationDetails.pan?.name) { {{ verificationDetails.pan.name }} @@ -678,6 +975,22 @@ error_outline {{ verificationError.aadhaar }}
+ +
+ Customer has applied for Aadhaar but not received it yet? + +
} diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss index c1c8ee91f..99885a877 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss @@ -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; } } diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts index 4ad0cee36..baa19c44b 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts @@ -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(); @@ -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 = diff --git a/src/app/clients/clients.component.html b/src/app/clients/clients.component.html index fee53218f..250023553 100644 --- a/src/app/clients/clients.component.html +++ b/src/app/clients/clients.component.html @@ -98,12 +98,6 @@ - - - Client ID - {{ row.id }} - - {{ 'labels.inputs.CIF Number' | translate }} diff --git a/src/app/clients/clients.component.ts b/src/app/clients/clients.component.ts index 98f9df007..d93a1ab2f 100644 --- a/src/app/clients/clients.component.ts +++ b/src/app/clients/clients.component.ts @@ -88,7 +88,6 @@ export class ClientsComponent implements OnInit { displayedColumns = [ 'displayName', - 'accountNumber', 'cifNumber', 'legalForm', 'status', diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index b230aa19e..01ac531af 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -809,6 +809,15 @@ export class ClientsService { return this.http.get('/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 { + return this.http.post('/kyc/risk-rules', rule); + } + /** Fetch code values by code ID */ getCodeValuesById(codeId: number): Observable { return this.http.get(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([]))); diff --git a/src/app/clients/create-client/create-client.component.html b/src/app/clients/create-client/create-client.component.html index 300080534..d87a5eea6 100644 --- a/src/app/clients/create-client/create-client.component.html +++ b/src/app/clients/create-client/create-client.component.html @@ -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" > diff --git a/src/app/clients/create-client/create-client.component.ts b/src/app/clients/create-client/create-client.component.ts index 2f144a5fe..573966949 100644 --- a/src/app/clients/create-client/create-client.component.ts +++ b/src/app/clients/create-client/create-client.component.ts @@ -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; }) ); diff --git a/src/app/clients/edit-client/edit-client.component.html b/src/app/clients/edit-client/edit-client.component.html index ab2a69972..b32a5c3cd 100644 --- a/src/app/clients/edit-client/edit-client.component.html +++ b/src/app/clients/edit-client/edit-client.component.html @@ -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" > @@ -342,7 +344,6 @@ [degreeQualificationOptions]="clientGeneralStep?.degreeQualificationOptions || []" [religionOptions]="clientGeneralStep?.religionOptions || []" [socialCategoryOptions]="clientGeneralStep?.socialCategoryOptions || []" - (submitEvent)="submit()" > @@ -350,9 +351,11 @@
- + @if (selectedTabIndex < totalTabs - 1) { + + }
@if (selectedTabIndex > 0) { } @if (selectedTabIndex < totalTabs - 1) { - } - @if (selectedTabIndex === totalTabs - 1 && areFormvalids()) { - }
diff --git a/src/app/clients/edit-client/edit-client.component.ts b/src/app/clients/edit-client/edit-client.component.ts index 53b4cce89..693aec60a 100644 --- a/src/app/clients/edit-client/edit-client.component.ts +++ b/src/app/clients/edit-client/edit-client.component.ts @@ -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; }) ); diff --git a/src/app/clients/kyc-document-renewal.util.spec.ts b/src/app/clients/kyc-document-renewal.util.spec.ts new file mode 100644 index 000000000..c263e9b19 --- /dev/null +++ b/src/app/clients/kyc-document-renewal.util.spec.ts @@ -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); + }); +}); diff --git a/src/app/clients/kyc-document-renewal.util.ts b/src/app/clients/kyc-document-renewal.util.ts new file mode 100644 index 000000000..d723bf8e0 --- /dev/null +++ b/src/app/clients/kyc-document-renewal.util.ts @@ -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'; +} diff --git a/src/app/clients/kyc-exception.util.spec.ts b/src/app/clients/kyc-exception.util.spec.ts index 1c429ff78..3f77c8e59 100644 --- a/src/app/clients/kyc-exception.util.spec.ts +++ b/src/app/clients/kyc-exception.util.spec.ts @@ -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}`; diff --git a/src/app/clients/kyc-exception.util.ts b/src/app/clients/kyc-exception.util.ts index 9d5f4185b..28cd78e0c 100644 --- a/src/app/clients/kyc-exception.util.ts +++ b/src/app/clients/kyc-exception.util.ts @@ -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); } diff --git a/src/app/clients/shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component.html b/src/app/clients/shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component.html new file mode 100644 index 000000000..0fe915a46 --- /dev/null +++ b/src/app/clients/shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component.html @@ -0,0 +1,84 @@ + + +
+
+ receipt_long +
+
Aadhaar Enrolment Receipt
+
{{ data.clientName || 'Client' }}
+
+
+ + +
+ info + {{ data.reason }} +
+ +

+ 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. +

+ + + Enrolment number (EID) + confirmation_number + + + {{ enrolmentIdHint }} + + + + + Enrolment date + + + + Date printed on the slip + + + @if (monthsSinceEnrolment !== null) { +
+ {{ receiptIsStale ? 'warning' : 'schedule' }} + + @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. + } + +
+ } + + + Remarks + + {{ remarks.length }}/500 + + + + The original UIDAI enrolment slip has been sighted and a copy filed with the account opening form. + +
+ + + + + +
diff --git a/src/app/clients/shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component.scss b/src/app/clients/shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component.scss new file mode 100644 index 000000000..416d490bd --- /dev/null +++ b/src/app/clients/shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component.scss @@ -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; +} diff --git a/src/app/clients/shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component.ts b/src/app/clients/shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component.ts new file mode 100644 index 000000000..8006a5c35 --- /dev/null +++ b/src/app/clients/shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component.ts @@ -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(MAT_DIALOG_DATA); + private dialogRef = inject(MatDialogRef); + + 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); + } +} diff --git a/src/app/clients/shared/kyc-upload-helper.service.ts b/src/app/clients/shared/kyc-upload-helper.service.ts index 235a7e67b..b27d995a4 100644 --- a/src/app/clients/shared/kyc-upload-helper.service.ts +++ b/src/app/clients/shared/kyc-upload-helper.service.ts @@ -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 => { + 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 { + 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 { + return this.fileToImage(blob as File); + } + private fileToImage(file: File): Promise { return new Promise((resolve, reject) => { const url = URL.createObjectURL(file); diff --git a/src/app/clients/shared/pan-declaration-dialog/pan-declaration-dialog.component.html b/src/app/clients/shared/pan-declaration-dialog/pan-declaration-dialog.component.html new file mode 100644 index 000000000..7bfa36340 --- /dev/null +++ b/src/app/clients/shared/pan-declaration-dialog/pan-declaration-dialog.component.html @@ -0,0 +1,176 @@ + + +
+
+ assignment_late +
+
PAN Verification Failed — Take Declaration
+
PAN {{ data.panNumber }}
+
+
+ + +
+ info + {{ data.failureMessage }} +
+ + @if (!data.unverifiable) { +

+ The Income Tax check matches on name and date of birth together 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. +

+ + +
+
+ + Submitted + As on PAN card + +
+ +
+ Name + + + + Name on PAN + + + + + @if (compared && nameOnPan.trim()) { + @if (nameMismatch) { + cancel + } @else { + check_circle + } + } + +
+ +
+ Date of Birth + + + + DOB on PAN + + + + + + + @if (compared && dobOnPan) { + @if (dobMismatch) { + cancel + } @else { + check_circle + } + } + +
+
+ + +
+ Does not match: + {{ mismatchLabel }} +
+ + @if (!comparisonComplete) { +

Enter both PAN-card values to see exactly which detail differs.

+ } + + @if (nothingToDeclare) { +

+ warning + 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. +

+ } + } @else { +

+ 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. +

+ } + + +
+
Signed declaration
+

+ 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. +

+ + @if (!hasDeclarationFile) { + + } @else { +
+ description + + {{ fileName }} + {{ fileSizeLabel }} + + + +
+ } + + @if (fileError) { +

+ error_outline + {{ fileError }} +

+ } +
+
+ + + + + +
diff --git a/src/app/clients/shared/pan-declaration-dialog/pan-declaration-dialog.component.scss b/src/app/clients/shared/pan-declaration-dialog/pan-declaration-dialog.component.scss new file mode 100644 index 000000000..b1067008c --- /dev/null +++ b/src/app/clients/shared/pan-declaration-dialog/pan-declaration-dialog.component.scss @@ -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; +} diff --git a/src/app/clients/shared/pan-declaration-dialog/pan-declaration-dialog.component.ts b/src/app/clients/shared/pan-declaration-dialog/pan-declaration-dialog.component.ts new file mode 100644 index 000000000..f8ee7279b --- /dev/null +++ b/src/app/clients/shared/pan-declaration-dialog/pan-declaration-dialog.component.ts @@ -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(MAT_DIALOG_DATA); + private dialogRef = inject(MatDialogRef); + + /** 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); + } +} diff --git a/src/app/core/alert/alert.model.ts b/src/app/core/alert/alert.model.ts index 92ec8d70d..e755e9748 100644 --- a/src/app/core/alert/alert.model.ts +++ b/src/app/core/alert/alert.model.ts @@ -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'; } diff --git a/src/app/core/http/error-handler.interceptor.ts b/src/app/core/http/error-handler.interceptor.ts index 0d9413845..0e47aaa09 100644 --- a/src/app/core/http/error-handler.interceptor.ts +++ b/src/app/core/http/error-handler.interceptor.ts @@ -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; diff --git a/src/app/fiu/fiu.component.html b/src/app/fiu/fiu.component.html index 69a529ac5..299bc6c7e 100644 --- a/src/app/fiu/fiu.component.html +++ b/src/app/fiu/fiu.component.html @@ -354,11 +354,12 @@

Live AML Alerts (Awaiting Triage)

Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate

- - @@ -416,7 +417,7 @@ } @empty { - No suspicious behavioral alerts. + No suspicious behavioral alerts awaiting triage. } @@ -424,12 +425,62 @@

Pending awaiting Whitelist/Escalate decision · - Cleared whitelisted — no report filed · → STR escalated to Principal Officer as an STR case


+ +
+
+

Cleared Alerts (Whitelisted)

+

Reviewed and closed as false positives — no STR filed. Retained with the evidence relied on.

+
+
+
+ + + + + + + + + + + + + + + @for (alert of amlWhitelistedAlerts; track alert.id) { + + + + + + + + + + + } @empty { + + + + } + +
IDClientTypeRuleAmountRaised OnCleared ByEvidence
{{ alert.id }}{{ alert.clientDisplayName }}{{ alert.transactionType }}{{ alert.ruleCode }} — {{ alert.ruleDescription }}₹{{ alert.amount | number: '1.0-0' }}{{ alert.raisedOn | date: 'medium' }}{{ alert.reviewedByUsername || '—' }} + {{ alert.reviewNotes || '—' }} +
No alerts have been cleared.
+
+

+ Cleared whitelisted — no report filed, evidence retained for + audit +

+ +
+
diff --git a/src/app/fiu/fiu.component.ts b/src/app/fiu/fiu.component.ts index aa311c96a..4bb75d87d 100644 --- a/src/app/fiu/fiu.component.ts +++ b/src/app/fiu/fiu.component.ts @@ -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 { diff --git a/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.html b/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.html index 4528ff587..50603de5b 100644 --- a/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.html +++ b/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.html @@ -40,6 +40,9 @@ + @@ -344,6 +347,87 @@
+ + +
+
+

Average Liabilities and Assets Month-wise during the year

+ +
+ + +
+
{{ statementHeading }}
+
Average Liabilities and Assets Month-wise during the year
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MonthCapitalReserve/Prov
Exclude OIR
DepositsBorrowingsOther LiabilitiesCash on HandBank BalancesInvestments
including
AdvancesOther Assets
Exclude IRA
CurrentFDs
+ {{ r.label }}
{{ r.sub }} +
{{ r.capital | number: '1.2-2' }}{{ r.reserveProv | number: '1.2-2' }}{{ r.deposits | number: '1.2-2' }}{{ r.borrowings | number: '1.2-2' }}{{ r.otherLiabilities | number: '1.2-2' }}{{ r.cashOnHand | number: '1.2-2' }}{{ r.bankCurrent | number: '1.2-2' }}{{ r.bankFds | number: '1.2-2' }}{{ r.investments | number: '1.2-2' }}{{ r.advances | number: '1.2-2' }}{{ r.otherAssets | number: '1.2-2' }}
{{ r.label }}{{ r.capital | number: '1.2-2' }}{{ r.reserveProv | number: '1.2-2' }}{{ r.deposits | number: '1.2-2' }}{{ r.borrowings | number: '1.2-2' }}{{ r.otherLiabilities | number: '1.2-2' }}{{ r.cashOnHand | number: '1.2-2' }}{{ r.bankCurrent | number: '1.2-2' }}{{ r.bankFds | number: '1.2-2' }}{{ r.investments | number: '1.2-2' }}{{ r.advances | number: '1.2-2' }}{{ r.otherAssets | number: '1.2-2' }}
+
+ +
+
Print Date : {{ statementPrintedAt }}
+
Report Ref No : {{ bank.reportRefNo }}  User Code:{{ statementUserCode }}
+
+
+
+
+
diff --git a/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.scss b/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.scss index c27fdcf76..9be650584 100644 --- a/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.scss +++ b/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.scss @@ -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; diff --git a/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.ts b/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.ts index 7ce0a4ec7..1fae7ce6e 100644 --- a/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.ts +++ b/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.ts @@ -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); + 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. + + /** " , " — 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 { diff --git a/src/app/home/timeout-dialog/session-timeout-dialog.component.html b/src/app/home/timeout-dialog/session-timeout-dialog.component.html index 756614b94..a3a9195ae 100644 --- a/src/app/home/timeout-dialog/session-timeout-dialog.component.html +++ b/src/app/home/timeout-dialog/session-timeout-dialog.component.html @@ -7,7 +7,16 @@ -->

{{ 'labels.heading.Session Timeout' | translate }}

-{{ 'labels.text.Session timed out after a period of inactivity' | translate }}. - - + +
+

{{ 'labels.text.Session timed out after a period of inactivity' | translate }}.

+

+ For your security, you will be logged out in + {{ countdown }} seconds. +

+
+
+ + + diff --git a/src/app/home/timeout-dialog/session-timeout-dialog.component.ts b/src/app/home/timeout-dialog/session-timeout-dialog.component.ts index 1687dba1c..de7ee93c0 100644 --- a/src/app/home/timeout-dialog/session-timeout-dialog.component.ts +++ b/src/app/home/timeout-dialog/session-timeout-dialog.component.ts @@ -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); + 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'); + } } diff --git a/src/app/kyc-management/kyc-management.component.html b/src/app/kyc-management/kyc-management.component.html index 256f9b7ec..893e1e000 100644 --- a/src/app/kyc-management/kyc-management.component.html +++ b/src/app/kyc-management/kyc-management.component.html @@ -190,66 +190,47 @@
-
3-Notice Dispatch Timeline
- Expiry: 30 Apr 2027 · Window: 31 Mar – 30 Apr +
Escalation Ladder — one channel at a time
+ + {{ reKycCompletedCount }} of {{ reKycSteps.length }} dispatched · Expiry: 30 Apr 2027 · Window: 31 Mar – + 30 Apr +
-
-
-
1
-
-
-
-
Notice 1 — Alert window opens
-
31 March 2027 · Auto-scheduled · ✓ Delivered
-
- SMS - Email + + @for (s of reKycSteps; track s.step; let i = $index) { +
+
+
{{ s.step }}
+
+
+
+ @if (isFirstOfNotice(i)) { +
Notice {{ s.notice }}
+ } +
Step {{ s.step }} — {{ s.channel }}
+
+ {{ s.date }} · {{ s.offset }} · + {{ reKycStatusLabel(s.status) }} +
+
+ {{ s.channel }} + {{ s.detail }} +
+ @if (s.status === 'PENDING') { +
+ + +
+ }
-
-
-
-
2
-
-
-
-
Notice 2 — Follow-up
-
10 April 2027 · +10 days · ⏳ Pending
-
- SMS - Email - IVR Call - Reg. Post -
-
-
-
-
-
3
-
-
-
-
Notice 3 — Final notice
-
20 April 2027 · +20 days · ✗ Not dispatched
-
- SMS - Email - IVR - Reg. Post - Home Visit -
-
- - -
-
-
+ }
!
@@ -295,39 +276,26 @@
-
Channels — Notice 3
+
Channel Sequence
+
-
-
📱
-
SMS
-
Registered mobile
-
-
-
📧
-
Email
-
Registered email
-
-
-
📞
-
IVR Call
-
Outbound IVR
-
-
-
📮
-
Reg. Post
-
India Post
-
-
-
🏦
-
Home Visit
-
Manual
-
-
-
📹
-
Video KYC
-
V-CIP app
-
+ @for (s of reKycSteps; track s.step) { +
+
{{ s.step }}. {{ s.channel }}
+
{{ s.detail }}
+
+ {{ reKycStatusLabel(s.status) }} +
+
+ }
@@ -734,8 +702,9 @@
KYC Exceptions Dashboard
-
diff --git a/src/app/kyc-management/kyc-management.component.scss b/src/app/kyc-management/kyc-management.component.scss index 22a9dae64..697f974b9 100644 --- a/src/app/kyc-management/kyc-management.component.scss +++ b/src/app/kyc-management/kyc-management.component.scss @@ -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; diff --git a/src/app/kyc-management/kyc-management.component.ts b/src/app/kyc-management/kyc-management.component.ts index e861a8b57..8da0f15d6 100644 --- a/src/app/kyc-management/kyc-management.component.ts +++ b/src/app/kyc-management/kyc-management.component.ts @@ -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 { + 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. diff --git a/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.html b/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.html new file mode 100644 index 000000000..90a6902c9 --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.html @@ -0,0 +1,126 @@ + + +

+ add_moderator + Add AML Rule +

+ + +
+
+ error_outline + {{ errorMessage }} +
+ + + Rule Description + + Rule description is required + Keep this under 100 characters + + + + Rule Reason / Criteria + + Reason is required + Keep this under 500 characters + + +
+ + Risk Category + + {{ r.label }} + + + + + Applies Over + + {{ w.label }} + + + + + Minimum Limit + ₹ + + Leave blank for a non-amount rule + + + + Maximum Limit + ₹ + + Blank means no upper limit + + + + Rule Type + + {{ t }} + + + + + Applies To + + {{ a }} + + + + + AML Check Status + + {{ s }} + + + + + Banned Entity Match + + {{ y }} + + +
+ +
+ error_outline + Maximum limit cannot be lower than the minimum — the rule would never match. +
+ +

+ Amount or window details also create the matching live-transaction rule. Leave them blank for a profile-only rule. +

+
+
+ + + + + diff --git a/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.scss b/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.scss new file mode 100644 index 000000000..4396ffb15 --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.scss @@ -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); + } +} diff --git a/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.ts b/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.ts new file mode 100644 index 000000000..fee1a4d17 --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.ts @@ -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); + + 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.' + ); + } +} diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html new file mode 100644 index 000000000..4470f0f0d --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html @@ -0,0 +1,194 @@ + + +
+
+ + {{ live ? 'Live' : 'Paused' }} + Updated {{ lastUpdated | date: 'HH:mm:ss' }} + +
+ +
+ + + +
+
+ +
+ cloud_off + Could not reach the alert feed. Retrying automatically. +
+ + +
+ Status + +
+ + +
+
+ {{ tier.count }} + {{ tier.label }} +
+
+ {{ unclassifiedCount }} + Unclassified +
+
+ {{ summaryTotal }} + Total Flagged +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Raised On + {{ + alert.raisedOnDate ? (alert.raisedOnDate | date: 'dd MMM yyyy, HH:mm') : '—' + }} + NEW + Transaction + {{ alert.transactionType }} + #{{ alert.transactionId }} + Client +
+ {{ alert.clientDisplayName || '—' }} + ID {{ alert.clientId }} +
+
Rule Triggered +
+ {{ alert.ruleCode }} + {{ alert.ruleDescription }} +
+
Amount{{ amount(alert) }}Status + {{ alert.status }} + Action + + +
+ + +
+
+ +
+ + {{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }} + + + + description + + + → STR + +
+
+
+ + + +
+ {{ activeStatus ? 'filter_alt_off' : 'verified_user' }} + No transactions flagged. The feed updates automatically as the engine raises alerts. + No alerts in this status. +
diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss new file mode 100644 index 000000000..67a98fce5 --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss @@ -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 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; +} diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts new file mode 100644 index 000000000..87cec7b0b --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts @@ -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(); + + /** 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([]); + 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 = {}; + + /** + * 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(); + /** Alerts that arrived after the officer started watching — highlighted until acknowledged. */ + newAlertIds = new Set(); + + @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(); + }); + } +} diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.html b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.html new file mode 100644 index 000000000..3191d9805 --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.html @@ -0,0 +1,135 @@ + + +

+ {{ isWhitelist ? 'verified' : 'report' }} + {{ title }} +

+ + + +
+
+ Transaction + + {{ data.alert.transactionType }} #{{ data.alert.transactionId }} + +
+
+ Client + + {{ data.alert.clientDisplayName || '—' }} + (ID {{ data.alert.clientId }}) + +
+
+ Amount + {{ amount() }} +
+
+ Rule + + {{ data.alert.ruleCode }} + — {{ data.alert.ruleDescription }} + +
+
+ Raised On + {{ data.alert.raisedOnDate | date: 'dd MMM yyyy, HH:mm:ss' }} +
+
+ + +
+ warning + + 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. + +
+ +
+ info_outline + The alert is closed with no report filed. The reason below is retained as the audit trail. +
+ +
+ error_outline + {{ errorMessage }} +
+ +
+ + +
+ Evidence relied on — tick all that apply +
+ + {{ t }} + +
+ Tick at least one form of evidence +
+ + + Document / Reference No. + + Optional — identifies the documents held on file + +
+ + + {{ notesLabel }} + + + Required — this is the audit record for the decision + + + Keep this under {{ detailMax }} characters + + + + +
+ Stored on the alert record + {{ composedNotes }} + + Saved to m_aml_alert.review_notes against alert #{{ data.alert.id }}, and shown in the NOTES column + of the FIU‑IND STR register. + +
+ +
+ error_outline + Too long to store — untick some evidence, or shorten the detail or reference. +
+
+
+ + + + + diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.scss b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.scss new file mode 100644 index 000000000..db9e26983 --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.scss @@ -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); + } +} diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts new file mode 100644 index 000000000..c5bc66317 --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts @@ -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); + readonly data = inject(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.' + ); + } +} diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.html b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.html new file mode 100644 index 000000000..f614c533c --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.html @@ -0,0 +1,217 @@ + + +
+ policy + + 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. + +
+ + +
+ + Flag after + + {{ t }}+ alerts + + + + + Within + + {{ w.label }} + + + + + Only awaiting review + + + + + + pending_actions + {{ needingReviewCount }} awaiting review + + + +
+ +
+ cloud_off + Could not load the review queue. +
+ + +
+ science + + Showing sample data — the AML engine returned no recurring alerts for this threshold and window. + These rows are for demonstration only and are not reportable. + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Customer +
+ {{ row.clientDisplayName }} + + ID {{ row.clientId }} · {{ row.accountNo }} · KYC risk + {{ riskLabel(row.clientRiskCategory) }} + +
+
Alerts + {{ row.alertCount }} + Severity +
+ {{ row.highRiskCount }} High + {{ row.openCount }} open · {{ row.escalatedCount }} escalated +
+
Rules Tripped +
+ {{ row.distinctRules }} distinct + {{ row.ruleCodes?.join(', ') }} +
+
Total Value{{ amount(row.totalAmount) }}Activity +
+ {{ row.lastAlertOnDate ? (row.lastAlertOnDate | date: 'dd MMM yyyy') : '—' }} + + first {{ row.firstAlertOnDate ? (row.firstAlertOnDate | date: 'dd MMM yyyy') : '—' }} + +
+
Review + Never reviewed + + New activity + +
+ {{ row.lastReviewOutcome }} + + {{ row.lastReviewedByUsername }} · {{ row.lastReviewedOnDate | date: 'dd MMM' }} + +
+
Action + + + +
+ + + + + + + + + + + + + + + + + + + + + +
Raised OnTransactionRuleAmountStatusReview Notes
{{ a.raisedOnDate ? (a.raisedOnDate | date: 'dd MMM yyyy, HH:mm') : '—' }}{{ a.transactionType }} #{{ a.transactionId }} + {{ a.ruleCode }} + {{ a.ruleDescription }} + {{ amount(a.amount) }}{{ a.status }}{{ a.reviewNotes || '—' }}
+
+
+ +
+ verified_user + + No customer has reached {{ minAlerts }} alerts in the selected window. Lower the threshold or widen the window to + see borderline cases. + +
diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss new file mode 100644 index 000000000..0f64353be --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss @@ -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; + } +} diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts new file mode 100644 index 000000000..e69fcd8d5 --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts @@ -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([]); + 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; + } +} diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.demo-data.ts b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.demo-data.ts new file mode 100644 index 000000000..ffab67a8e --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.demo-data.ts @@ -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.' + } + ] + } + ]; +} diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-review-dialog.component.html b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-review-dialog.component.html new file mode 100644 index 000000000..2859f142d --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-review-dialog.component.html @@ -0,0 +1,127 @@ + + +

+ fact_check + Review Repeat Activity — {{ data.row.clientDisplayName }} +

+ + +
+
+ Customer + + {{ data.row.clientDisplayName }} + (ID {{ data.row.clientId }}{{ data.row.accountNo ? ' · ' + data.row.accountNo : '' }}) + +
+
+ Alerts + + {{ data.row.alertCount }} in the last {{ data.windowDays }} days · + {{ data.row.highRiskCount }} High risk · {{ data.row.distinctRules }} distinct rule(s) + +
+
+ Value + {{ amount(data.row.totalAmount) }} +
+
+ Rules + {{ data.row.ruleCodes?.join(', ') || '—' }} +
+
+ + +
+ Triggering transactions +
+ + + + + + + + + + + + + + + + + + + +
Raised OnTransactionRuleAmountStatus
{{ a.raisedOnDate | date: 'dd MMM yyyy, HH:mm' }}{{ a.transactionType }} #{{ a.transactionId }}{{ a.ruleCode }}{{ amount(a.amount) }}{{ a.status }}
+
+
+ +
+ history + + Last reviewed by {{ data.row.lastReviewedByUsername }} on + {{ data.row.lastReviewedOnDate | date: 'dd MMM yyyy' }} at {{ data.row.alertCountAtLastReview }} alert(s) — + {{ data.row.lastReviewOutcome }}. {{ data.row.lastReviewNotes }} + +
+ +
+ error_outline + {{ errorMessage }} +
+ +
+
+ Conclusion + + + {{ o.label }} + — {{ o.hint }} + + +
+ + + Why does this customer keep surfacing? + + + Required — this is the audit record for the decision + + Keep this under 1000 characters + + +

+ Recorded against the current count of {{ data.row.alertCount }} alert(s). The customer returns to this queue if + the engine flags them again. +

+
+
+ + + + + diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-review-dialog.component.scss b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-review-dialog.component.scss new file mode 100644 index 000000000..fa4d99c95 --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-review-dialog.component.scss @@ -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; + } +} diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-review-dialog.component.ts b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-review-dialog.component.ts new file mode 100644 index 000000000..df80560eb --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-review-dialog.component.ts @@ -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); + readonly data = inject(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.' + ); + } +} diff --git a/src/app/rbi-circulars/aml-rules/aml-rules.component.html b/src/app/rbi-circulars/aml-rules/aml-rules.component.html index 106151acf..7747c541c 100644 --- a/src/app/rbi-circulars/aml-rules/aml-rules.component.html +++ b/src/app/rbi-circulars/aml-rules/aml-rules.component.html @@ -18,119 +18,192 @@
+ - -
- Applies over - -
+ + +
+ + - - - - - - + +
+ Applies over + +
- - - - - +
Rule Srno{{ element.orderPosition }}Rule Description{{ element.ruleName }}
+ + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - -
Rule Srno{{ element.orderPosition }}Risk Category - - - {{ getRiskCategoryLabel(element.riskCategory) }} - - Rule Description{{ element.ruleName }}Limit Basis - {{ getLimitBasis(element) }} - - {{ getNonNumericCriterion(element) }} - - Risk Category + + + {{ getRiskCategoryLabel(element.riskCategory) }} + + Minimum Limit{{ getMinLimit(element) }}Limit Basis + {{ getLimitBasis(element) }} + + {{ getNonNumericCriterion(element) }} + + Maximum Limit - {{ getMaxLimit(element) }} - Minimum Limit{{ getMinLimit(element) }}Applies Over - {{ periodLabel(element) }} - Maximum Limit + {{ getMaxLimit(element) }} + Rule Reason / Criteria{{ element.reason }}Applies Over + {{ periodLabel(element) }} + AML Check Status - - {{ element.amlCheckStatus || 'CLEAR' }} - - Rule Reason / Criteria{{ element.reason }}Banned Match - - {{ element.bannedEntityMatch || 'NO' }} - - AML Check Status + + {{ element.amlCheckStatus || 'CLEAR' }} + +
+ + + Banned Match + + + {{ element.bannedEntityMatch || 'NO' }} + + + - - + + + -
- info_outline - No AML rules found in the database. -
+ + + +
+ {{ isSearching ? 'search_off' : 'info_outline' }} + No AML rules found in the database. + + No rules match “{{ searchTerm }}”. + + +
+
+
+ + + + +
+ +
+
+
+ + + + +
+ +
+
+
+
diff --git a/src/app/rbi-circulars/aml-rules/aml-rules.component.scss b/src/app/rbi-circulars/aml-rules/aml-rules.component.scss index e5de73029..2c230ac84 100644 --- a/src/app/rbi-circulars/aml-rules/aml-rules.component.scss +++ b/src/app/rbi-circulars/aml-rules/aml-rules.component.scss @@ -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; diff --git a/src/app/rbi-circulars/aml-rules/aml-rules.component.ts b/src/app/rbi-circulars/aml-rules/aml-rules.component.ts index 4d72eb407..f161186c5 100644 --- a/src/app/rbi-circulars/aml-rules/aml-rules.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-rules.component.ts @@ -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([]); 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 = {}; + @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(); } /** diff --git a/src/app/shared/error-dialog/error-dialog.component.html b/src/app/shared/error-dialog/error-dialog.component.html index 892421df5..d6abcddc3 100644 --- a/src/app/shared/error-dialog/error-dialog.component.html +++ b/src/app/shared/error-dialog/error-dialog.component.html @@ -10,7 +10,7 @@
-

We encountered an issue. Please try again.

+

{{ title }}

@if (!showAsCode) {

{{ displayData }}

} diff --git a/src/app/shared/error-dialog/error-dialog.component.ts b/src/app/shared/error-dialog/error-dialog.component.ts index fb0d96e49..d66c6e570 100644 --- a/src/app/shared/error-dialog/error-dialog.component.ts +++ b/src/app/shared/error-dialog/error-dialog.component.ts @@ -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('
');
+    this.showAsCode = this.displayData.startsWith('
');
+  }
+
+  /**
+   * 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) : '';
   }
diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.html
index 53b446b53..f0dda9546 100644
--- a/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.html
+++ b/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.html
@@ -104,8 +104,8 @@
                 {{ element.displayName }}
               
             
-            
-              {{ 'labels.inputs.Client ID' | translate }}
+            
+              {{ 'labels.inputs.CIF Number' | translate }}
               
-                {{ element.id }}
+                {{ element.cifNumber }}
               
             
             
diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.ts
index f44b7b943..ec1f55228 100644
--- a/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.ts
+++ b/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.ts
@@ -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();
     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.
    */
diff --git a/src/app/tds-reports/tds-interest-report.component.html b/src/app/tds-reports/tds-interest-report.component.html
index 9626f2a6e..45d79127d 100644
--- a/src/app/tds-reports/tds-interest-report.component.html
+++ b/src/app/tds-reports/tds-interest-report.component.html
@@ -80,7 +80,7 @@
   
+ +
+ + +
+
@@ -160,7 +185,7 @@ - @for (row of rows; track trackRow($index, row)) { + @for (row of pagedRows; track trackRow($index, row)) {
{{ row.deductee.name }}
@@ -311,15 +336,70 @@ } } - @if (rows.length === 0) { + @if (visibleRows.length === 0) { - No deductees match the current filters. + No customers in this section match the filters. }
+ + @if (visibleRows.length > 0) { +
+
+ + Showing {{ rangeStart | number }}–{{ rangeEnd | number }} of + {{ visibleRows.length | number }} + {{ selectedTab === 'paying' ? 'deductees paying TDS' : 'deductees not paying TDS' }} + + +
+ + @if (pageCount > 1) { +
+ + + Page {{ currentPage + 1 | number }} of {{ pageCount | number }} + + +
+ } +
+ }
diff --git a/src/app/tds-reports/tds-interest-report.component.ts b/src/app/tds-reports/tds-interest-report.component.ts index 5fecd298a..ee89da5d6 100644 --- a/src/app/tds-reports/tds-interest-report.component.ts +++ b/src/app/tds-reports/tds-interest-report.component.ts @@ -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 { diff --git a/src/app/tds-reports/tds-reports.shared.scss b/src/app/tds-reports/tds-reports.shared.scss index a40222577..8deb7825c 100644 --- a/src/app/tds-reports/tds-reports.shared.scss +++ b/src/app/tds-reports/tds-reports.shared.scss @@ -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; +} diff --git a/src/app/web-app.component.ts b/src/app/web-app.component.ts index b6c9e2445..43ad9a332 100644 --- a/src/app/web-app.component.ts +++ b/src/app/web-app.component.ts @@ -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); }); } } diff --git a/src/assets/env.js b/src/assets/env.js index e795d09bc..beb7fb517 100644 --- a/src/assets/env.js +++ b/src/assets/env.js @@ -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; diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 9b861c735..b16b1f967 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260806', - 'hash': 'ddb215de' + 'version': '260811', + 'hash': '4281a596' }, 'allow_switching_backend_instance': true }; diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index 46d3c8ebc..cffbec166 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -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, diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 7c96f3dc6..f4cf45cb8 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -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,