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
{{ 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
+
+ @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.
+
+ }
+
visibility
View
@@ -1027,7 +1257,9 @@
filter_none
Combined preview (front + back)
- Saved as a single PNG. Re-crop a side to update the merged file.
+
+ Preview only — each side is saved as its own file. Re-crop a side to update it.
+
@@ -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.
-
@@ -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.
+
+ }
- OK
+ @if (data.offerDeclaration) {
+ Close
+ Take Declaration
+ } @else {
+ OK
+ }
`,
@@ -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 @@
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.
+
+
+
+
+ Cancel
+
+ Save Receipt
+
+
+
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.
+
+ Date of Birth
+ {{ data.submittedDob || '—' }}
+
+
+ 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.
+