Files
cbsnexora/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts
T
niteshandClaude Sonnet 5 c1fc95d9b7 fix(clients): minor-specific KYC document types (Birth Certificate/School ID) never appeared
filterDocTypesForMinor() only ever removes entries. The initial (pre-DOB, isMinor=false) seed
already stripped Birth Certificate/School ID Card out of kycDocumentTypes, so the isMinor
ngOnChanges handler re-filtering that same shortened list could never bring them back once the
client turned out to be a minor. Rebuild from the full defaultDocumentTypes source instead
(matching how the initial seed itself is built) whenever isMinor changes and the backend KYC
template hasn't already taken over. Also gives School ID Card the "Government-issued ID"
category it was missing, so it groups with Aadhaar/Passport/Birth Certificate instead of "Other".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:56:30 +05:30

1811 lines
66 KiB
TypeScript

/**
* 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, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
import {
MatDialog,
MAT_DIALOG_DATA,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { Inject } from '@angular/core';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatSelectTrigger } from '@angular/material/select';
import { MatDivider } from '@angular/material/divider';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton, MatButton } from '@angular/material/button';
import { MatCheckbox } from '@angular/material/checkbox';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ClientsService } from '../../clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
@Component({
selector: 'mifosx-client-kyc-step',
templateUrl: './client-kyc-step.component.html',
styleUrls: ['./client-kyc-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
MatDivider,
MatIcon,
MatIconButton,
MatButton,
MatCheckbox,
MatProgressSpinner,
FaIconComponent,
MatSelectTrigger
]
})
export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
private formBuilder = inject(UntypedFormBuilder);
private dialog = inject(MatDialog);
private clientsService = inject(ClientsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private kycUpload = inject(KycUploadHelperService);
/** Set by parent after client is created */
@Input() clientId: string = '';
@Input() isNonIndividual = false;
/** Minor clients don't hold a PAN card — hides the PAN section on the individual KYC form */
@Input() isMinor = false;
/** Constitution metadata (panLabel, showCin, etc.) passed from general step */
@Input() constitutionMeta: any = null;
/** Entity full name (non-individual) — used for enterprise PAN verification */
@Input() entityName: string = '';
/** Read-only mode — disables form editing until user clicks edit icon */
@Input() readOnly = false;
today = new Date();
kycId: string = '';
/** Whether existing KYC data has been loaded (edit mode) */
kycLoaded = false;
/** KYC status from API: 100=PENDING, 200=IN_PROGRESS, 300=VERIFIED */
kycStatus: number = 0;
/** Whether the KYC record is in VERIFIED state (needs re-edit command) */
get isKycVerified(): boolean {
return this.kycStatus === 300;
}
/** KYC Document Types — populated from API template, falls back to defaults */
kycDocumentTypes: { name: string; code: string; mandatory: boolean }[] = [];
/** Chip-group state for KYC document picker (mirrors identity proof in identifier dialog) */
kycDocProofs: ProofType[] = [];
kycDocGroups: { label: string; items: ProofType[] }[] = [];
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;
kycDocMergedPreview: string | null = null;
/**
* Documents already on the server for the linked client_identifier row (edit mode).
* Populated in loadExistingKycIdentifier() so the form can show the user what proof
* they uploaded previously instead of presenting an empty file picker.
*
* Each entry: { id, name, fileName, type, size, previewUrl (base64 data URL for images) }
*/
existingKycIdentifierId: string | null = null;
existingKycDocuments: {
id: string;
name: string;
fileName: string;
type: string;
size: number;
previewUrl: string | null;
}[] = [];
/** Set true after a save attempt so we can highlight missing photo/signature boxes */
photoSigTouched = false;
/** Either an in-memory crop or an image already on the server */
get hasPhoto(): boolean {
return !!this.photographPreview;
}
get hasSignature(): boolean {
return !!this.signaturePreview;
}
/** Photo + signature are mandatory before leaving the KYC tab */
get photoSigComplete(): boolean {
return this.hasPhoto && this.hasSignature;
}
/** Mark uploads as touched so the UI shows the missing-required state */
markPhotoSigTouched(): void {
this.photoSigTouched = true;
}
/** Human-readable label for each `kycForm` control that can carry a `required` error —
* used to build a specific "why is Save & Next disabled" message instead of a generic one.
* Controls not listed here (e.g. purely pattern-validated ones like panNumber/gstinNumber)
* only block when the user has actually typed something invalid, which is covered separately. */
private static readonly REQUIRED_FIELD_LABELS: Record<string, string> = {
kycDocumentName: 'KYC Document Type',
kycDocumentNumber: 'KYC Document Number',
amlCheckStatus: 'AML Check Status',
bannedEntityMatch: 'Banned Entity List Match'
};
/** Names every currently-invalid `kycForm` control (required-but-empty, or an entered value
* that fails its pattern) in plain English — surfaced via the Save & Next tooltip so staff
* aren't left staring at a greyed-out button with no clue what's still missing. Whether or
* not photo/signature previews are filled has no bearing on this list (they aren't part of
* `kycForm` at all — see `photoSigComplete`). */
get invalidFieldLabels(): string[] {
if (!this.kycForm) {
return [];
}
return Object.keys(this.kycForm.controls)
.filter((key) => this.kycForm.get(key)?.invalid)
.map((key) => ClientKycStepComponent.REQUIRED_FIELD_LABELS[key] || key);
}
/** Fallback document types when template API is unavailable */
private defaultDocumentTypes = [
{ name: 'Aadhaar Card', code: 'AADHAAR' },
{ name: 'Passport', code: 'PASSPORT' },
{ name: 'PAN Card', code: 'PAN' },
{ name: 'Driving Licence', code: 'DRIVING_LICENSE' },
{ name: 'Voter ID', code: 'VOTER_ID' },
{ name: 'NREGA Job Card', code: 'NREGA_JOB_CARD' },
{ name: 'National Population Register Letter', code: 'NPR_LETTER' },
{ name: 'Birth Certificate', code: 'BIRTH_CERTIFICATE' },
{ name: 'School ID Card', code: 'SCHOOL_ID_CARD' }
];
/** Constitution-based KYC document types for non-individual clients */
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
HUF: [
{ name: 'PAN Card of HUF', mandatory: true },
{ name: 'Declaration by Karta (with name, address & HUF member details)', mandatory: true },
{ name: 'KYC of Karta (OVDs + PAN + Photo)', mandatory: true },
{ name: 'Proof of Address of HUF', mandatory: false }
],
PRIVATE_LIMITED: [
{ name: 'Certificate of Incorporation', mandatory: true },
{ name: 'Memorandum & Articles of Association', mandatory: true },
{ name: 'PAN of Company', mandatory: true },
{ name: 'Board Resolution (authorizing signatories)', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
PUBLIC_LIMITED: [
{ name: 'Certificate of Incorporation', mandatory: true },
{ name: 'Memorandum & Articles of Association', mandatory: true },
{ name: 'PAN of Company', mandatory: true },
{ name: 'Board Resolution (authorizing signatories)', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
LLP: [
{ name: 'Certificate of Incorporation', mandatory: true },
{ name: 'LLP Agreement', mandatory: true },
{ name: 'PAN of LLP', mandatory: true },
{ name: 'Designated Partners Resolution', mandatory: true },
{ name: 'KYC of Designated Partners / Authorized Signatories', mandatory: true }
],
PARTNERSHIP: [
{ name: 'Registration Certificate', mandatory: false },
{ name: 'Partnership Deed', mandatory: true },
{ name: 'PAN of Firm', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
TRUST: [
{ name: 'Registration Certificate', mandatory: true },
{ name: 'Trust Deed', mandatory: true },
{ name: 'PAN of Trust', mandatory: true },
{ name: 'KYC of Trustees / Authorized Signatories', mandatory: true }
],
SOCIETY: [
{ name: 'Registration Certificate', mandatory: true },
{ name: 'PAN of Society', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
PROPRIETORSHIP: [
{ name: 'Proof of Business Existence (GST Certificate / Shop License)', mandatory: true },
{ name: 'PAN of Proprietor', mandatory: true },
{ name: "Proprietor's KYC (OVDs + PAN + Photo)", mandatory: true }
],
ASSOCIATION: [
{ name: 'Registration Certificate', mandatory: true },
{ name: 'PAN', mandatory: true },
{ name: 'Resolution authorizing signatories', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
GOVT_PSU: [
{ name: 'Government Notification / Order', mandatory: true },
{ name: 'PAN (if applicable)', mandatory: false },
{ name: 'Authorized Signatory KYC', mandatory: true }
],
COOPERATIVE: [
{ name: 'Registration Certificate', mandatory: true },
{ name: 'PAN', mandatory: true },
{ name: 'Resolution authorizing signatories', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
]
};
/** KYC template data from API */
kycTemplate: any = null;
kycTemplateLoaded = false;
/** Risk cards */
riskCards = [
{ value: 1, label: 'Low Risk', years: 10, color: 'green' },
{ value: 2, label: 'Medium Risk', years: 8, color: 'amber' },
{ value: 3, label: 'High Risk', years: 2, color: 'red' }
];
/** PAN verification */
panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
panVerifying = false;
panVerifyError: string = '';
panVerifyName: string = '';
panVerifiedOn: string = '';
/** Aadhaar verification */
aadhaarVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
aadhaarVerifying = false;
aadhaarVerifyError: string = '';
aadhaarVerifyName: string = '';
aadhaarVisible = false;
aadhaarOtpSent = false;
aadhaarOtp = '';
aadhaarOtpVerifying = false;
aadhaarOtpReferenceId = '';
/** OTP digit boxes */
otpDigits: string[] = [
'',
'',
'',
'',
'',
''
];
/** Resend countdown */
resendCountdown = 0;
private resendTimer: any = null;
onOtpDigitInput(index: number, event: Event): void {
const input = event.target as HTMLInputElement;
const val = input.value.replace(/\D/g, '');
this.otpDigits[index] = val.charAt(0) || '';
input.value = this.otpDigits[index];
// Auto-advance to next box
if (this.otpDigits[index] && index < 5) {
const next = input.parentElement?.querySelectorAll('input')[index + 1];
if (next) (next as HTMLInputElement).focus();
}
this.aadhaarOtp = this.otpDigits.join('');
}
onOtpKeydown(index: number, event: KeyboardEvent): void {
if (event.key === 'Backspace' && !this.otpDigits[index] && index > 0) {
const inputs = (event.target as HTMLElement).parentElement?.querySelectorAll('input');
if (inputs) {
this.otpDigits[index - 1] = '';
(inputs[index - 1] as HTMLInputElement).value = '';
(inputs[index - 1] as HTMLInputElement).focus();
}
this.aadhaarOtp = this.otpDigits.join('');
}
}
onOtpPaste(event: ClipboardEvent): void {
event.preventDefault();
const pasted = (event.clipboardData?.getData('text') || '').replace(/\D/g, '').slice(0, 6);
for (let i = 0; i < 6; i++) {
this.otpDigits[i] = pasted.charAt(i) || '';
}
this.aadhaarOtp = this.otpDigits.join('');
// Focus last filled or last box
const inputs = (event.target as HTMLElement).closest('.otp-digits')?.querySelectorAll('input');
if (inputs) {
const focusIdx = Math.min(pasted.length, 5);
(inputs[focusIdx] as HTMLInputElement).focus();
}
}
startResendCountdown(): void {
this.resendCountdown = 30;
if (this.resendTimer) clearInterval(this.resendTimer);
this.resendTimer = setInterval(() => {
this.resendCountdown--;
if (this.resendCountdown <= 0) {
clearInterval(this.resendTimer);
this.resendTimer = null;
}
}, 1000);
}
resetOtpDigits(): void {
this.otpDigits = [
'',
'',
'',
'',
'',
''
];
this.aadhaarOtp = '';
}
/** PAN-Aadhaar seeding status (from API response) */
panAadhaarSeedingStatus: string = '';
/** Enterprise PAN verification (non-individual) */
entPanVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
entPanVerifying = false;
entPanVerifyName = '';
entPanVerifiedOn = '';
/** GSTIN verification */
gstinVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
gstinVerifying = false;
gstinVerifyName = '';
gstinVerifiedOn = '';
gstinVerifyState = '';
/** CIN verification (dummy) */
cinVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
cinVerifying = false;
cinVerifiedOn = '';
cinCompanyName = '';
cinProgressValue = 0;
cinCurrentStep = 0;
cinProgressSteps = [
{ label: 'Connecting to MCA database...', icon: 'globe' as any },
{ label: 'Validating CIN format...', icon: 'search' as any },
{ label: 'Fetching company details...', icon: 'database' as any },
{ label: 'Verifying registration status...', icon: 'check-circle' as any }
];
private cinProgressTimer: any;
/** File uploads */
photographFile: File | null = null;
photographPreview: string | null = null;
signatureFile: File | null = null;
signaturePreview: string | null = null;
/** Existing signature document ID (for re-upload/delete) */
existingSignatureDocId: string = '';
/** KYC Form */
kycForm: UntypedFormGroup = this.formBuilder.group({
hasPan: [true],
panNumber: [
'',
[
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/),
Validators.maxLength(10)
]
],
aadhaarNumber: [
'',
[
Validators.pattern(/^\d{12}$/),
Validators.maxLength(12)
]
],
panAadhaarSeedingStatus: [''],
gstinNumber: [
'',
[
Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/),
Validators.maxLength(15)
]
],
enterprisePan: [
'',
[
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/),
Validators.maxLength(10)
]
],
cinNumber: [
'',
[
Validators.pattern(/^[A-Z]{1}[0-9]{5}[A-Z]{2}[0-9]{4}[A-Z]{3}[0-9]{6}$/),
Validators.maxLength(21)
]
],
incorporationDate: [''],
kycDocumentName: [
'',
Validators.required
],
kycDocumentNumber: [
'',
[
Validators.required,
Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/),
Validators.maxLength(30)
]
],
kycDocIssueDate: [''],
kycDocExpiryDate: [''],
riskCategory: [1],
remarks: [''],
amlCheckStatus: [
'CLEAR',
Validators.required
],
bannedEntityMatch: [
'NO',
Validators.required
],
willfulDefaulter: ['NO'],
ecgcSalFlag: ['NO'],
dormantFlag: ['ACTIVE'],
accountStatus: ['ACTIVE'],
freezeReason: [''],
freezeAuthority: [''],
form60Type: ['FORM_60'],
form60Date: [''],
form15Applicable: [false],
form15Type: ['15G'],
form15FY: ['']
});
/** RBI-accepted OVDs for a minor client — mirrors the backend's minor branch in
* ClientKycApiResource.getRequiredDocuments(). Voter ID, Driving Licence, PAN and NREGA
* all require adult eligibility; National Population Register Letter is deliberately
* excluded too (not offered as a minor OVD in this system). */
private static readonly MINOR_ELIGIBLE_DOC_CODES = new Set([
'AADHAAR',
'PASSPORT',
'BIRTH_CERTIFICATE',
'SCHOOL_ID_CARD'
]);
/** Birth Certificate / School ID Card are minor-specific OVDs — not relevant once the
* client is an adult, so they're hidden the other direction. */
private static readonly MINOR_ONLY_DOC_CODES = new Set([
'BIRTH_CERTIFICATE',
'SCHOOL_ID_CARD'
]);
private filterDocTypesForMinor<T extends { name: string; code?: string }>(docs: T[]): T[] {
if (this.isMinor) {
return docs.filter((d) => ClientKycStepComponent.MINOR_ELIGIBLE_DOC_CODES.has((d.code || '').toUpperCase()));
}
return docs.filter((d) => !ClientKycStepComponent.MINOR_ONLY_DOC_CODES.has((d.code || '').toUpperCase()));
}
ngOnInit(): void {
this.seedDefaultDocTypes();
this.loadExistingKycIfNeeded();
this.loadKycTemplate();
if (this.isNonIndividual && this.constitutionMeta) {
this.updateDocTypesForConstitution();
}
}
/** Seed the document-type picker from defaults, filtered by the current isMinor input.
* Only seeds when the list is empty — the template API (loadKycTemplate) takes over once
* a clientId is available. */
private seedDefaultDocTypes(): void {
if (this.kycDocumentTypes.length) return;
this.kycDocumentTypes = this.filterDocTypesForMinor(
this.defaultDocumentTypes.map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}))
);
this.rebuildKycDocPicker();
}
/**
* Resets transient KYC-tab state for a brand-new client.
*
* This app's RouteReusableStrategy (core/route/route-reusable-strategy.ts) keeps this SAME
* component instance alive across repeat visits to /clients/create, so `ngOnInit` never
* re-runs for a second "create new client" session. Without this, the document-type picker
* (and any PAN/Aadhaar verification state, uploaded photo/signature) would otherwise leak
* from whatever client was created in the previous session into the new one. Called by
* CreateClientComponent.resetForNewClient() whenever a fresh create session begins.
*/
resetForNewClient(): void {
this.kycForm.reset({
hasPan: true,
riskCategory: 1,
amlCheckStatus: 'CLEAR',
bannedEntityMatch: 'NO',
willfulDefaulter: 'NO',
ecgcSalFlag: 'NO',
dormantFlag: 'ACTIVE',
accountStatus: 'ACTIVE',
form60Type: 'FORM_60',
form15Type: '15G'
});
this.kycId = '';
this.kycLoaded = false;
this.kycStatus = 0;
this.kycTemplate = null;
this.kycTemplateLoaded = false;
this.kycDocumentTypes = [];
this.selectedKycDoc = null;
this.kycDocFiles = { front: null, back: null };
this.kycDocMergedFile = null;
this.kycDocMergedPreview = null;
this.existingKycIdentifierId = null;
this.existingKycDocuments = [];
this.photoSigTouched = false;
this.panVerifyStatus = 'NONE';
this.panVerifying = false;
this.panVerifyError = '';
this.panVerifyName = '';
this.panVerifiedOn = '';
this.aadhaarVerifyStatus = 'NONE';
this.aadhaarVerifying = false;
this.aadhaarVerifyError = '';
this.aadhaarVerifyName = '';
this.aadhaarVisible = false;
this.aadhaarOtpSent = false;
this.resetOtpDigits();
this.aadhaarOtpReferenceId = '';
this.panAadhaarSeedingStatus = '';
this.entPanVerifyStatus = 'NONE';
this.entPanVerifying = false;
this.entPanVerifyName = '';
this.entPanVerifiedOn = '';
this.gstinVerifyStatus = 'NONE';
this.gstinVerifying = false;
this.gstinVerifyName = '';
this.gstinVerifiedOn = '';
this.gstinVerifyState = '';
this.cinVerifyStatus = 'NONE';
this.cinVerifying = false;
this.cinVerifiedOn = '';
this.cinCompanyName = '';
this.photographFile = null;
this.photographPreview = null;
this.signatureFile = null;
this.signaturePreview = null;
this.existingSignatureDocId = '';
this.seedDefaultDocTypes();
}
ngOnDestroy(): void {
if (this.cinProgressTimer) clearInterval(this.cinProgressTimer);
if (this.resendTimer) clearInterval(this.resendTimer);
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['clientId'] && !changes['clientId'].firstChange) {
this.loadExistingKycIfNeeded();
this.loadKycTemplate();
}
if (changes['readOnly'] && this.kycForm) {
if (this.readOnly) {
this.kycForm.disable({ emitEvent: false });
} else {
this.kycForm.enable({ emitEvent: false });
}
}
if (changes['constitutionMeta'] && this.isNonIndividual) {
this.updateDocTypesForConstitution();
}
if (changes['isMinor']) {
if (this.isMinor) {
this.kycForm.get('hasPan')?.setValue(false);
this.kycForm.get('panNumber')?.setValue('');
}
// Re-derive from the full default list, not by re-filtering the already-narrowed
// `kycDocumentTypes` — filterDocTypesForMinor() only ever REMOVES entries, so once the
// initial (pre-DOB, isMinor=false) seed has already stripped out Birth Certificate /
// School ID Card, re-filtering that same shortened list can never bring them back once
// the client turns out to be a minor. Once the backend KYC template has loaded
// (kycTemplateLoaded), that list is already minor-aware and authoritative — leave it alone.
if (!this.kycTemplateLoaded) {
const rebuilt = this.filterDocTypesForMinor(
this.defaultDocumentTypes.map((d) => ({ name: d.name, code: d.code, mandatory: false }))
);
this.kycDocumentTypes = rebuilt;
const currentName = this.kycForm.get('kycDocumentName')?.value;
if (currentName && !rebuilt.some((d) => d.name === currentName)) {
this.kycForm.get('kycDocumentName')?.setValue('');
}
this.rebuildKycDocPicker();
}
}
}
/** Update KYC document types based on selected constitution type */
private updateDocTypesForConstitution(): void {
const constitutionValue = this.constitutionMeta?.value;
if (constitutionValue && this.constitutionDocTypes[constitutionValue]) {
this.kycDocumentTypes = this.constitutionDocTypes[constitutionValue].map((doc) => ({
name: doc.name,
code: doc.name,
mandatory: doc.mandatory
}));
// Reset selected document type since options changed
this.kycForm.get('kycDocumentName')?.setValue('');
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
this.rebuildKycDocPicker();
}
}
/** Rebuild chip-group picker model from kycDocumentTypes */
private rebuildKycDocPicker(): void {
this.kycDocProofs = (this.kycDocumentTypes || []).map((d, i) =>
normalizeProofType({ id: d.code || d.name || i, name: d.name, code: d.code, description: d.code || d.name })
);
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;
}
selectKycDoc(proof: ProofType): void {
this.selectedKycDoc = proof;
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;
}
isKycDocSelected(proof: ProofType): boolean {
return this.selectedKycDoc?.code === proof.code && this.selectedKycDoc?.name === proof.name;
}
async onKycDocFile(event: Event, slot: 'front' | 'back'): Promise<void> {
const input = event.target as HTMLInputElement;
const raw = input.files?.[0];
input.value = ''; // allow re-picking the same filename
if (!raw) return;
if (raw.size > 5 * 1024 * 1024) return;
const cropped = await this.kycUpload.pickWithCrop(raw, `Crop ${slot} side`);
if (!cropped) return;
this.kycDocFiles[slot] = cropped;
await this.refreshKycDocMerged();
}
async reCropKycDocFile(slot: 'front' | 'back'): Promise<void> {
const current = this.kycDocFiles[slot];
if (!current || !current.type.startsWith('image/')) return;
const cropped = await this.kycUpload.pickWithCrop(current, `Re-crop ${slot} side`);
if (!cropped) return;
this.kycDocFiles[slot] = cropped;
await this.refreshKycDocMerged();
}
removeKycDocFile(slot: 'front' | 'back'): void {
this.kycDocFiles[slot] = null;
this.kycDocMergedFile = null;
this.kycDocMergedPreview = null;
}
/**
* Open the saved KYC ID-proof document in a new browser tab.
*
* Same flow as the address-step variant: images use the already-loaded
* base64 data URL; PDFs (or anything without a cached preview) are fetched
* fresh and rendered via a temporary blob URL.
*/
viewExistingKycDoc(): void {
if (!this.existingKycIdentifierId || this.existingKycDocuments.length === 0) {
return;
}
const doc = this.existingKycDocuments[0];
if (doc.previewUrl) {
window.open(doc.previewUrl, '_blank', 'noopener');
return;
}
this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId, doc.id).subscribe({
next: (blob: Blob) => {
const url = URL.createObjectURL(blob);
const w = window.open(url, '_blank', 'noopener');
if (w) {
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
},
error: () => {
/* silent — UI still shows the saved filename */
}
});
}
private async refreshKycDocMerged(): Promise<void> {
const proof = this.selectedKycDoc;
if (!proof || proof.sides !== 2 || !this.kycDocFiles.front || !this.kycDocFiles.back) {
this.kycDocMergedFile = null;
this.kycDocMergedPreview = null;
return;
}
const merged = await this.kycUpload.mergeFrontBack(
this.kycDocFiles.front,
this.kycDocFiles.back,
proof.name || 'kyc-document'
);
if (merged) {
this.kycDocMergedFile = merged.file;
this.kycDocMergedPreview = merged.preview;
} else {
this.kycDocMergedFile = null;
this.kycDocMergedPreview = 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.
*/
get kycDocPayload(): {
proof: ProofType | null;
proofId: any;
documentKey: string;
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 });
}
}
return {
proof: this.selectedKycDoc,
proofId: this.selectedKycDoc?.id ?? '',
documentKey: this.kycForm.get('kycDocumentNumber')?.value || '',
files
};
}
/** Load existing KYC data if clientId is set (edit mode) */
private loadExistingKycIfNeeded(): void {
if (!this.clientId || this.kycLoaded) return;
this.clientsService.getClientKyc(this.clientId).subscribe({
next: (data: any) => {
this.kycLoaded = true;
if (data) {
this.kycId = data.id?.toString() || '';
this.kycStatus = data.status?.id || data.kycStatus || 0;
// Helper to parse API date arrays [y,m,d] or strings into Date objects
const parseDate = (val: any): Date | string => {
if (Array.isArray(val) && val.length >= 3) {
return new Date(val[0], val[1] - 1, val[2]);
}
if (val && typeof val === 'string') {
const d = new Date(val);
return isNaN(d.getTime()) ? '' : d;
}
return '';
};
this.kycForm.patchValue({
panNumber: data.panNumber || '',
aadhaarNumber: data.aadhaarNumberMasked || '',
gstinNumber: data.gstinNumber || '',
enterprisePan: data.enterprisePan || '',
cinNumber: data.cinNumber || '',
incorporationDate: parseDate(data.incorporationDate),
// KYC Document
kycDocumentName: data.kycDocumentName || '',
kycDocumentNumber: data.kycDocumentNumber || '',
kycDocIssueDate: parseDate(data.kycDocIssueDate),
kycDocExpiryDate: parseDate(data.kycDocExpiryDate),
// Risk & Remarks
riskCategory: data.riskCategory || 1,
remarks: data.remarks || '',
hasPan: !!data.panNumber,
// AML & Compliance
amlCheckStatus: data.amlCheckStatus || 'CLEAR',
bannedEntityMatch: data.bannedEntityMatch || 'NO',
willfulDefaulter: data.willfulDefaulter || 'NO',
ecgcSalFlag: data.ecgcSalFlag || 'NO',
dormantFlag: data.dormantFlag || 'ACTIVE',
// Account Status & Freeze
accountStatus: data.accountStatus || 'ACTIVE',
freezeReason: data.freezeReason || '',
freezeAuthority: data.freezeAuthority || '',
// Form 60 / Form 15
form60Type: data.form60Type || 'FORM_60',
form60Date: parseDate(data.form60Date),
form15Applicable: data.form15Applicable || false,
form15Type: data.form15Type || '15G',
form15FY: data.form15FY || ''
});
// Sync chip-picker selection with the prefilled document name
if (data.kycDocumentName) {
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null;
}
// Set verification statuses from existing data
if (data.panVerified) {
this.panVerifyStatus = 'VERIFIED';
this.panVerifyName = data.panFullName || data.panHolderName || '';
const rawDate = data.panVerifiedOn;
if (Array.isArray(rawDate) && rawDate.length >= 3) {
this.panVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
} else if (rawDate) {
this.panVerifiedOn = rawDate;
}
}
if (data.aadhaarVerified) {
this.aadhaarVerifyStatus = 'VERIFIED';
this.aadhaarVerifyName = data.aadhaarHolderName || 'Aadhaar Verified';
}
if (data.panAadhaarSeedingStatus) {
this.panAadhaarSeedingStatus = data.panAadhaarSeedingStatus;
}
if (data.gstinVerified) {
this.gstinVerifyStatus = 'VERIFIED';
this.gstinVerifyName = data.gstinTradeName || data.gstinLegalName || '';
this.gstinVerifyState = data.gstinState || '';
const rawGstinDate = data.gstinVerifiedOn;
if (Array.isArray(rawGstinDate) && rawGstinDate.length >= 3) {
this.gstinVerifiedOn = `${rawGstinDate[2]?.toString().padStart(2, '0')}/${rawGstinDate[1]?.toString().padStart(2, '0')}/${rawGstinDate[0]}`;
} else if (rawGstinDate) {
this.gstinVerifiedOn = rawGstinDate;
}
}
}
// Load existing photo & signature
this.loadExistingPhoto();
this.loadExistingSignature();
// Show the proof file the user uploaded previously, by matching the KYC document
// name against the client_identifier rows on the server.
this.loadExistingKycIdentifier(data?.kycDocumentName);
},
error: () => {
// No existing KYC — that's fine for new clients
this.kycLoaded = true;
}
});
}
/**
* Pull the client_identifier row that backs the KYC proof and any documents attached to
* it. Address proof + KYC proof both end up as client_identifier rows (see
* create-client.saveKycDocProof + saveAddressProof) — so we filter against `kycDocProofs`
* before falling back, otherwise the KYC tab could mistakenly show the address proof's file.
*
* @param kycDocumentName the document type captured on the KYC record — used to pick
* the right identifier when a client has more than one
*/
private loadExistingKycIdentifier(kycDocumentName: string | undefined): void {
if (!this.clientId) return;
this.clientsService.getClientIdentifiers(this.clientId).subscribe({
next: (identifiers: any[]) => {
if (!identifiers || identifiers.length === 0) return;
const isKycType = (i: any) => {
const n = i?.documentType?.name?.toLowerCase();
return !!n && this.kycDocProofs.some((p) => p.name?.toLowerCase() === n);
};
// 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
? identifiers.find((i: any) => i?.documentType?.name?.toLowerCase() === kycDocumentName.toLowerCase())
: null;
const identifier = match || identifiers.find(isKycType);
if (!identifier?.id) return;
this.existingKycIdentifierId = identifier.id.toString();
// Backfill document type + number on the form in case the KYC record didn't have
// them (older clients were created via the identifier flow only).
if (!this.kycForm.get('kycDocumentName')?.value && identifier?.documentType?.name) {
this.kycForm.patchValue({
kycDocumentName: identifier.documentType.name,
kycDocumentNumber: identifier.documentKey || ''
});
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === identifier.documentType.name) || null;
}
this.clientsService.getClientIdentificationDocuments(this.existingKycIdentifierId).subscribe({
next: (docs: any[]) => {
if (!docs || docs.length === 0) return;
this.existingKycDocuments = docs.map((d: any) => ({
id: d.id?.toString() || '',
name: d.name || '',
fileName: d.fileName || '',
type: d.type || '',
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: () => {}
});
}
},
error: () => {}
});
},
error: () => {}
});
}
/** Fetch existing client profile photo */
private loadExistingPhoto(): void {
if (!this.clientId) return;
this.clientsService.getClientProfileImage(this.clientId).subscribe({
next: (imageData: any) => {
if (imageData) {
this.photographPreview = imageData;
}
},
error: () => {}
});
}
/** Fetch existing client signature from documents */
private loadExistingSignature(): void {
if (!this.clientId) return;
this.clientsService.getClientDocuments(this.clientId).subscribe({
next: (documents: any[]) => {
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced
// their signature before uploads became single-slot, so pick the highest id (latest)
// rather than the first unordered match.
const sigDoc = documents
?.filter((d: any) => d.name === 'clientSignature')
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (sigDoc?.id) {
this.existingSignatureDocId = sigDoc.id.toString();
this.clientsService.getClientSignatureImage(this.clientId, this.existingSignatureDocId).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]);
}
this.signaturePreview = 'data:' + blob.type + ';base64,' + btoa(binary);
},
error: () => {}
});
}
},
error: () => {}
});
}
/** Load KYC template to get required documents based on client type */
private loadKycTemplate(): void {
if (!this.clientId || this.kycTemplateLoaded) return;
this.clientsService.getClientKycTemplate(this.clientId).subscribe({
next: (template: any) => {
this.kycTemplate = template;
this.kycTemplateLoaded = true;
if (template?.requiredDocuments?.length > 0) {
this.kycDocumentTypes = this.filterDocTypesForMinor(
template.requiredDocuments.map((doc: any) => ({
name: doc.name,
code: doc.code || doc.name,
mandatory: doc.mandatory ?? false
}))
);
// Update kycDocumentName validator based on mandatory docs
const hasMandatory = this.kycDocumentTypes.some((d) => d.mandatory);
if (hasMandatory) {
this.kycForm.get('kycDocumentName')?.setValidators(Validators.required);
}
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
this.rebuildKycDocPicker();
}
},
error: () => {
// Fallback to defaults if template API unavailable
this.kycTemplateLoaded = true;
this.kycDocumentTypes = this.filterDocTypesForMinor(
this.defaultDocumentTypes.map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}))
);
this.rebuildKycDocPicker();
}
});
}
/** Save or update KYC, then execute callback */
private saveKycAndDo(payload: any, callback: () => void, onError: (msg: string) => void) {
if (this.kycId) {
const { kycType, ...updatePayload } = payload;
// If KYC is VERIFIED, remarks are mandatory — use re-edit command
if (this.isKycVerified) {
const remarks = this.kycForm.get('remarks')?.value?.trim();
if (!remarks) {
onError('Remarks are mandatory when updating a verified KYC record.');
this.kycForm.get('remarks')?.markAsTouched();
return;
}
updatePayload.remarks = remarks;
this.clientsService.reEditClientKyc(this.clientId, this.kycId, updatePayload).subscribe({
next: () => {
// Status transitions to IN_PROGRESS, PAN verification is invalidated
this.kycStatus = 200;
this.panVerifyStatus = 'NONE';
this.panVerifyName = '';
this.panVerifiedOn = '';
callback();
},
error: (err: any) =>
onError(
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Failed to re-edit KYC.'
)
});
} else {
// Normal update for non-verified records
this.clientsService.updateClientKyc(this.clientId, this.kycId, updatePayload).subscribe({
next: () => callback(),
error: (err: any) => onError(err?.error?.defaultUserMessage || 'Failed to update KYC.')
});
}
} else {
// First time — use POST to create
this.clientsService.saveClientKyc(this.clientId, payload).subscribe({
next: (res: any) => {
this.kycId = res?.resourceId?.toString() || '';
if (this.kycId) {
callback();
} else {
onError('Failed to save KYC.');
}
},
error: (err: any) => onError(err?.error?.defaultUserMessage || 'Failed to save KYC.')
});
}
}
/** Verify PAN — saves PAN-only KYC data, then verifies */
verifyPan() {
if (!this.clientId) {
this.panVerifyError = 'Client must be created first. Complete General tab and click Next.';
return;
}
const pan = (this.kycForm.value.panNumber || '').toUpperCase().trim();
if (!pan) {
this.panVerifyError = 'Enter PAN number first.';
this.kycForm.get('panNumber')?.markAsTouched();
return;
}
// Block API call when the PAN format is invalid — Fineract would accept
// the raw value otherwise and persist a junk record.
if (!/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/.test(pan)) {
this.panVerifyError = 'Invalid PAN format. Expected pattern: ABCDE1234F (5 letters, 4 digits, 1 letter).';
this.kycForm.get('panNumber')?.setErrors({ pattern: true });
this.kycForm.get('panNumber')?.markAsTouched();
return;
}
this.panVerifying = true;
this.panVerifyError = '';
this.panVerifyName = '';
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
const panPayload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
panNumber: pan,
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000'
};
this.saveKycAndDo(
panPayload,
() => {
this.clientsService.verifyPAN(this.clientId, this.kycId).subscribe({
next: (res: any) => {
this.panVerifying = false;
if (res?.changes?.panVerified === true) {
// Full name from API
const resolvedName =
res.changes?.panFullName ||
res.changes?.panHolderName ||
[
res.changes?.panFirstName,
res.changes?.panLastName
]
.filter(Boolean)
.join(' ') ||
'';
// 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."
);
return;
}
this.panVerifyStatus = 'VERIFIED';
this.panVerifyName = resolvedName;
// Date — could be string or array [y,m,d]
const rawDate = res.changes?.panVerifiedOn;
if (Array.isArray(rawDate)) {
this.panVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
} else if (rawDate) {
this.panVerifiedOn = rawDate;
} else {
const now = new Date();
this.panVerifiedOn = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
}
// Seeding status
if (res.changes?.panAadhaarSeedingStatus) {
this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus;
}
} else {
this.panVerifyStatus = 'FAILED';
this.showPanErrorAlert(res?.changes?.panVerificationMessage || 'PAN verification failed.');
}
},
error: (err: any) => {
this.panVerifying = false;
this.panVerifyStatus = 'FAILED';
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
'PAN verification failed.';
this.showPanErrorAlert(msg);
}
});
},
(msg) => {
this.panVerifying = false;
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
*/
verifyEnterprisePan() {
if (!this.clientId) return;
const pan = (this.kycForm.value.enterprisePan || '').toUpperCase().trim();
if (!pan) {
this.showPanErrorAlert('Enter Enterprise PAN number first.');
this.kycForm.get('enterprisePan')?.markAsTouched();
return;
}
if (!/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/.test(pan)) {
this.showPanErrorAlert(
'Invalid Enterprise PAN format. Expected pattern: ABCDE1234F (5 letters, 4 digits, 1 letter).'
);
this.kycForm.get('enterprisePan')?.setErrors({ pattern: true });
this.kycForm.get('enterprisePan')?.markAsTouched();
return;
}
this.entPanVerifying = true;
const dateFormat = this.settingsService.dateFormat;
const incorpDate = this.kycForm.value.incorporationDate;
// Same payload structure as individual — backend uses panNumber + client name/DOB for verification
const payload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
panNumber: pan, // Company PAN → panNumber
dateFormat,
aadhaarNumberMasked: 'XXXXXXXX0000' // Placeholder — not applicable for entities
};
this.saveKycAndDo(
payload,
() => {
this.clientsService.verifyPAN(this.clientId, this.kycId).subscribe({
next: (res: any) => {
this.entPanVerifying = false;
if (res?.changes?.panVerified === true) {
this.entPanVerifyStatus = 'VERIFIED';
this.entPanVerifyName = res.changes?.panFullName || res.changes?.panHolderName || '';
const rawDate = res.changes?.panVerifiedOn;
if (Array.isArray(rawDate)) {
this.entPanVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
} else {
const now = new Date();
this.entPanVerifiedOn =
rawDate ||
`${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
}
} else {
this.entPanVerifyStatus = 'FAILED';
this.showPanErrorAlert(
res?.changes?.panVerificationMessage ||
'Enterprise PAN verification failed. Ensure company name and date of incorporation match the PAN records.'
);
}
},
error: (err: any) => {
this.entPanVerifying = false;
this.entPanVerifyStatus = 'FAILED';
this.showPanErrorAlert(
err?.error?.errors?.[0]?.defaultUserMessage || 'Enterprise PAN verification failed.'
);
}
});
},
(msg) => {
this.entPanVerifying = false;
this.showPanErrorAlert(msg);
}
);
}
/** Verify GSTIN via API */
verifyGstin() {
if (!this.clientId) return;
const gstin = (this.kycForm.value.gstinNumber || '').toUpperCase().trim();
if (!gstin) {
this.showPanErrorAlert('Enter GSTIN number first.');
this.kycForm.get('gstinNumber')?.markAsTouched();
return;
}
if (!/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/.test(gstin)) {
this.showPanErrorAlert('Invalid GSTIN format. Expected pattern: 22AAAAA0000A1Z5 (15 characters).');
this.kycForm.get('gstinNumber')?.setErrors({ pattern: true });
this.kycForm.get('gstinNumber')?.markAsTouched();
return;
}
this.gstinVerifying = true;
const dateFormat = this.settingsService.dateFormat;
const payload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
gstinNumber: gstin,
dateFormat,
panNumber: this.kycForm.value.enterprisePan || this.kycForm.value.panNumber || null,
aadhaarNumberMasked: 'XXXXXXXX0000'
};
this.saveKycAndDo(
payload,
() => {
this.clientsService.verifyGSTIN(this.clientId, this.kycId).subscribe({
next: (res: any) => {
this.gstinVerifying = false;
const changes = res?.changes || res;
if (changes?.gstinVerified === true) {
this.gstinVerifyStatus = 'VERIFIED';
this.gstinVerifyName = changes?.gstinTradeName || changes?.gstinLegalName || '';
this.gstinVerifyState = changes?.gstinState || '';
const rawDate = changes?.gstinVerifiedOn;
if (Array.isArray(rawDate)) {
this.gstinVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
} else {
const now = new Date();
this.gstinVerifiedOn =
rawDate ||
`${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
}
} else {
this.gstinVerifyStatus = 'FAILED';
this.showPanErrorAlert(
changes?.gstinVerificationMessage ||
'GSTIN verification failed. Please check the GSTIN number and try again.'
);
}
},
error: (err: any) => {
this.gstinVerifying = false;
this.gstinVerifyStatus = 'FAILED';
this.showPanErrorAlert(
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
'GSTIN verification failed.'
);
}
});
},
(msg) => {
this.gstinVerifying = false;
this.showPanErrorAlert(msg);
}
);
}
/** Verify CIN (dummy — no real API) */
verifyCin() {
const cin = (this.kycForm.value.cinNumber || '').toUpperCase().trim();
if (!cin) {
this.showPanErrorAlert('Enter CIN number first.');
this.kycForm.get('cinNumber')?.markAsTouched();
return;
}
if (!/^[A-Z]{1}[0-9]{5}[A-Z]{2}[0-9]{4}[A-Z]{3}[0-9]{6}$/.test(cin)) {
this.showPanErrorAlert('Invalid CIN format. Expected pattern: L17110MH1973PLC019786 (21 characters).');
this.kycForm.get('cinNumber')?.setErrors({ pattern: true });
this.kycForm.get('cinNumber')?.markAsTouched();
return;
}
this.cinVerifying = true;
this.cinProgressValue = 0;
this.cinCurrentStep = 0;
const stepDurations = [
800,
1000,
1200,
800
];
const totalDuration = stepDurations.reduce((a, b) => a + b, 0);
let elapsed = 0;
const interval = 50;
this.cinProgressTimer = setInterval(() => {
elapsed += interval;
let accum = 0;
for (let i = 0; i < stepDurations.length; i++) {
accum += stepDurations[i];
if (elapsed <= accum) {
this.cinCurrentStep = i;
break;
}
}
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 100);
if (elapsed >= totalDuration) {
clearInterval(this.cinProgressTimer);
this.cinCurrentStep = stepDurations.length;
setTimeout(() => {
this.cinVerifying = false;
this.cinVerifyStatus = 'VERIFIED';
this.cinCompanyName = 'Verified via MCA';
const now = new Date();
this.cinVerifiedOn = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
}, 300);
}
}, interval);
}
/** Send Aadhaar OTP — saves Aadhaar-only KYC data, then sends OTP */
sendAadhaarOtp() {
if (!this.clientId) {
this.aadhaarVerifyError = 'Client must be created first. Complete General tab and click Next.';
return;
}
const aadhaar = (this.kycForm.value.aadhaarNumber || '').trim();
if (!aadhaar) {
this.aadhaarVerifyError = 'Enter Aadhaar number first.';
this.kycForm.get('aadhaarNumber')?.markAsTouched();
return;
}
if (!/^\d{12}$/.test(aadhaar)) {
this.aadhaarVerifyError = 'Invalid Aadhaar format. Aadhaar must be exactly 12 digits.';
this.kycForm.get('aadhaarNumber')?.setErrors({ pattern: true });
this.kycForm.get('aadhaarNumber')?.markAsTouched();
return;
}
this.aadhaarVerifying = true;
this.aadhaarVerifyError = '';
const aadhaarMasked = 'XXXXXXXX' + aadhaar.slice(-4);
const aadhaarPayload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
aadhaarNumberMasked: aadhaarMasked
};
const panVal = this.kycForm.value.panNumber;
if (panVal) {
aadhaarPayload.panNumber = panVal;
}
this.saveKycAndDo(
aadhaarPayload,
() => {
this.clientsService.sendAadhaarOtp(this.clientId, this.kycId, aadhaar).subscribe({
next: (res: any) => {
this.aadhaarVerifying = false;
this.aadhaarOtpSent = true;
this.aadhaarOtpReferenceId = res?.changes?.aadhaarRefNumber || '';
this.resetOtpDigits();
this.startResendCountdown();
},
error: (err: any) => {
this.aadhaarVerifying = false;
this.aadhaarVerifyError = err?.error?.defaultUserMessage || 'Failed to send OTP.';
}
});
},
(msg) => {
this.aadhaarVerifying = false;
this.aadhaarVerifyError = msg;
}
);
}
/** Lets the user go back and fix a wrong Aadhaar number after an OTP was already sent —
* unlocks the (readonly-while-OTP-pending) input and discards the in-flight OTP session,
* since it was requested for the now-being-corrected number. */
changeAadhaarNumber(): void {
this.aadhaarOtpSent = false;
this.aadhaarVerifyError = '';
this.aadhaarOtpReferenceId = '';
this.resetOtpDigits();
this.resendCountdown = 0;
if (this.resendTimer) {
clearInterval(this.resendTimer);
this.resendTimer = null;
}
}
/** Verify Aadhaar OTP */
verifyAadhaarOtp() {
if (!this.aadhaarOtp || !this.clientId || !this.kycId) return;
this.aadhaarOtpVerifying = true;
this.aadhaarVerifyError = '';
this.clientsService
.verifyAadhaarOtp(this.clientId, this.kycId, this.aadhaarOtp, this.aadhaarOtpReferenceId)
.subscribe({
next: (res: any) => {
this.aadhaarOtpVerifying = false;
if (res?.changes?.aadhaarVerified === true) {
this.aadhaarVerifyStatus = 'VERIFIED';
this.aadhaarVerifyName = res.changes?.aadhaarHolderName || 'Aadhaar Verified';
this.aadhaarOtpSent = false;
if (res.changes?.panAadhaarSeedingStatus) {
this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus;
}
} else {
this.aadhaarVerifyError = res?.changes?.aadhaarVerificationMessage || 'OTP verification failed';
}
},
error: (err: any) => {
this.aadhaarOtpVerifying = false;
this.aadhaarVerifyError = err?.error?.defaultUserMessage || 'OTP verification failed.';
}
});
}
private showPanErrorAlert(message: string) {
this.panVerifyError = '';
this.dialog.open(PanErrorDialogComponent, {
width: '480px',
data: { message }
});
}
getRenewalYears(): number {
switch (this.kycForm.value.riskCategory) {
case 1:
return 10;
case 2:
return 8;
case 3:
return 2;
default:
return 10;
}
}
getKycExpiryDate(years: number): string {
const d = new Date();
d.setFullYear(d.getFullYear() + years);
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
];
return `${String(d.getDate()).padStart(2, '0')} ${months[d.getMonth()]} ${d.getFullYear()}`;
}
async onPhotoSelected(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files?.length) return;
const file = input.files[0];
input.value = '';
await this.openPhotoCropDialog(file);
}
/** Webcam capture entry point for the profile photograph — feeds the captured frame
* through the same crop dialog + staging/upload path as the file-picker flow above,
* so behaviour (immediate upload in edit mode vs staged-until-submit in create mode)
* stays identical regardless of capture source. */
async captureWebcamPhoto(): Promise<void> {
const dialogRef = this.dialog.open(CaptureImageDialogComponent, {
data: { captureTarget: 'profile' }
});
dialogRef.afterClosed().subscribe(async (imageDataUrl: string) => {
if (!imageDataUrl) return;
const file = dataUrlToFile(imageDataUrl, 'webcam-photograph.jpg');
await this.openPhotoCropDialog(file);
});
}
private async openPhotoCropDialog(file: File): Promise<void> {
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
width: '680px',
maxWidth: '95vw',
data: { file, title: 'Crop Client Photograph', aspectRatio: 1, maxWidth: 300, maxHeight: 300 }
});
dialogRef.afterClosed().subscribe((result: any) => {
if (result) {
this.photographFile = result.croppedFile;
this.photographPreview = result.previewUrl;
// In edit mode, upload immediately
if (this.clientId) {
this.clientsService
.uploadClientProfileImage(this.clientId, result.croppedFile)
.subscribe({ error: () => {} });
}
}
});
}
async onSignatureSelected(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files?.length) return;
const file = input.files[0];
input.value = '';
await this.openSignatureCropDialog(file);
}
/** Webcam capture entry point for the specimen signature — photographs a paper
* signature, then reuses the same crop dialog + staging/upload path as the file-picker
* flow above (additive to the existing draw-pad / upload options, not a replacement). */
async captureWebcamSignature(): Promise<void> {
const dialogRef = this.dialog.open(CaptureImageDialogComponent, {
data: { captureTarget: 'signature' }
});
dialogRef.afterClosed().subscribe(async (imageDataUrl: string) => {
if (!imageDataUrl) return;
const file = dataUrlToFile(imageDataUrl, 'webcam-signature.jpg');
await this.openSignatureCropDialog(file);
});
}
private async openSignatureCropDialog(file: File): Promise<void> {
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
width: '680px',
maxWidth: '95vw',
data: { file, title: 'Crop Specimen Signature', aspectRatio: 2.5, maxWidth: 400, maxHeight: 160 }
});
dialogRef.afterClosed().subscribe((result: any) => {
if (result) {
this.signatureFile = result.croppedFile;
this.signaturePreview = result.previewUrl;
// In edit mode, delete old signature doc then upload new one
if (this.clientId) {
if (this.existingSignatureDocId) {
this.clientsService.deleteClientDocument(this.clientId, this.existingSignatureDocId).subscribe({
next: () => {
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
next: (res: any) => {
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
},
error: () => {}
});
},
error: () => {
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
next: (res: any) => {
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
},
error: () => {}
});
}
});
} else {
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
next: (res: any) => {
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
},
error: () => {}
});
}
}
}
});
}
removePhoto() {
this.photographFile = null;
this.photographPreview = null;
// In edit mode, delete from server
if (this.clientId) {
this.clientsService.deleteClientProfileImage(this.clientId).subscribe({ error: () => {} });
}
}
removeSignature() {
this.signatureFile = null;
this.signaturePreview = null;
// In edit mode, delete from server
if (this.clientId && this.existingSignatureDocId) {
this.clientsService.deleteClientDocument(this.clientId, this.existingSignatureDocId).subscribe({
next: () => {
this.existingSignatureDocId = '';
},
error: () => {}
});
}
}
get kycData(): any {
const formVal = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value;
const aadhaarRaw = formVal.aadhaarNumber || '';
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : null;
const dateFormat = this.settingsService.dateFormat;
const v = formVal;
const formatDate = (d: any) => (d instanceof Date ? this.dateUtils.formatDate(d, dateFormat) : d || null);
// Backend regex on these identifiers is upper-case only; tolerate user-typed lowercase
// / whitespace here so the save doesn't 400 on
// "validation.msg.CLIENTKYC.panNumber.does.not.match.regexp"
// (or the equivalent for gstin / cin / enterprisePan).
const norm = (s: any): string | null => {
const out = (s || '').toString().toUpperCase().replace(/\s+/g, '');
return out.length ? out : null;
};
const panNorm = norm(v.panNumber);
const enterprisePanNorm = norm(v.enterprisePan);
const gstinNorm = norm(v.gstinNumber);
const cinNorm = norm(v.cinNumber);
return {
locale: 'en',
dateFormat,
kycType: 1,
riskCategory: v.riskCategory || 1,
// For non-individual: Company PAN is used as panNumber for verification
panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null,
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
panAadhaarSeedingStatus: this.panAadhaarSeedingStatus || null,
gstinNumber: gstinNorm,
enterprisePan: enterprisePanNorm,
cinNumber: cinNorm,
incorporationDate: formatDate(v.incorporationDate),
// KYC Document
kycDocumentName: v.kycDocumentName || null,
kycDocumentNumber: v.kycDocumentNumber || null,
kycDocIssueDate: formatDate(v.kycDocIssueDate),
kycDocExpiryDate: formatDate(v.kycDocExpiryDate),
// AML & Compliance
amlCheckStatus: v.amlCheckStatus || null,
bannedEntityMatch: v.bannedEntityMatch || null,
willfulDefaulter: v.willfulDefaulter || null,
ecgcSalFlag: v.ecgcSalFlag || null,
dormantFlag: v.dormantFlag || null,
// Freeze / Debit Controls
accountStatus: v.accountStatus || null,
freezeReason: v.freezeReason || null,
freezeAuthority: v.freezeAuthority || null,
// Form 60 / Form 15
form60Type: v.form60Type || null,
form60Date: formatDate(v.form60Date),
form15Applicable: v.form15Applicable || false,
form15Type: v.form15Type || null,
form15FY: v.form15FY || null,
remarks: v.remarks || null
};
}
get hasKycData(): boolean {
const v = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value;
return !!(
v.panNumber ||
v.aadhaarNumber ||
v.gstinNumber ||
v.enterprisePan ||
v.cinNumber ||
v.kycDocumentName ||
v.form15Applicable
);
}
}
/**
* PAN Error Alert Dialog
*/
@Component({
selector: 'mifosx-pan-error-dialog',
template: `
<div class="pan-err-dialog">
<div class="pan-err-header">
<mat-icon class="pan-err-icon">error</mat-icon>
<span>PAN Verification Failed</span>
</div>
<mat-dialog-content class="pan-err-body">
<p>{{ data.message }}</p>
</mat-dialog-content>
<mat-dialog-actions align="end" class="pan-err-actions">
<button mat-flat-button color="primary" mat-dialog-close>OK</button>
</mat-dialog-actions>
</div>
`,
styles: [
`
.pan-err-dialog {
max-width: 460px;
}
.pan-err-header {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 24px 12px;
border-bottom: 1px solid #fecaca;
background: #fef2f2;
font-size: 16px;
font-weight: 600;
color: #991b1b;
}
.pan-err-icon {
color: #dc2626;
}
.pan-err-body {
padding: 18px 24px !important;
font-size: 14px;
line-height: 1.6;
color: #475569;
}
.pan-err-actions {
padding: 10px 24px 16px !important;
margin: 0 !important;
}
`
],
imports: [
MatIcon,
MatDialogContent,
MatDialogActions,
MatDialogClose,
STANDALONE_SHARED_IMPORTS
]
})
export class PanErrorDialogComponent {
data = inject<{ message: string }>(MAT_DIALOG_DATA);
}