/** * 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'; import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component'; @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; @Input() calculatedRisk: { category: number; reason: string; amlCheckStatus?: string; bannedEntityMatch?: string; willfulDefaulter?: string; ecgcSalFlag?: string; dormantFlag?: string; } | null = null; 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 = { 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' } ]; /** Generic RBI-prescribed KYC documents for legal entities (Master Direction on KYC, * Annexure I) — shown for non-individual clients before a specific constitution type is * picked. Individual OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/NPR) don't apply to entities. */ private nonIndividualDefaultDocTypes = [ { name: 'Certificate of Incorporation / Registration Certificate', code: 'CERT_INCORPORATION' }, { name: 'PAN Card of Entity', code: 'ENTITY_PAN' }, { name: 'Memorandum & Articles of Association / Partnership Deed / Trust Deed', code: 'CONSTITUTIVE_DOC' }, { name: 'Board Resolution / Authorization Letter', code: 'BOARD_RESOLUTION' }, { name: 'KYC of Authorized Signatory / Power of Attorney Holder', code: 'AUTH_SIGNATORY_KYC' }, { name: 'GST Registration Certificate', code: 'GST_CERTIFICATE' } ]; /** Constitution-based KYC document types for non-individual clients */ private constitutionDocTypes: Record = { 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' } ]; riskCalculationReason = ''; showRiskRulesTable = false; /** 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], allowRiskOverride: [false], allowAmlOverride: [false], 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(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())); } /** Individual-only OVDs vs. entity-only RBI docs — never both, and minor filtering only * ever applies to individuals. */ private getDefaultDocTypes(): { name: string; code: string }[] { return this.isNonIndividual ? this.nonIndividualDefaultDocTypes : this.filterDocTypesForMinor(this.defaultDocumentTypes); } ngOnInit(): void { this.seedDefaultDocTypes(); this.loadExistingKycIfNeeded(); this.loadKycTemplate(); if (this.isNonIndividual && this.constitutionMeta) { this.updateDocTypesForConstitution(); } this.kycForm.get('allowRiskOverride')?.valueChanges.subscribe((allowed: boolean) => { const riskControl = this.kycForm.get('riskCategory'); if (allowed) { if (!this.readOnly) { riskControl?.enable(); } } else { riskControl?.disable(); if (this.calculatedRisk) { riskControl?.setValue(this.calculatedRisk.category); } } }); this.kycForm.get('allowAmlOverride')?.valueChanges.subscribe((allowed: boolean) => { const amlControls = [ this.kycForm.get('amlCheckStatus'), this.kycForm.get('bannedEntityMatch'), this.kycForm.get('willfulDefaulter'), this.kycForm.get('ecgcSalFlag'), this.kycForm.get('dormantFlag') ]; if (allowed) { if (!this.readOnly) { amlControls.forEach((ctrl) => ctrl?.enable()); } } else { amlControls.forEach((ctrl) => ctrl?.disable()); this.applyAutoCalculatedAmlFlags(); } }); if (!this.kycForm.get('allowRiskOverride')?.value) { this.kycForm.get('riskCategory')?.disable(); } if (!this.kycForm.get('allowAmlOverride')?.value) { this.kycForm.get('amlCheckStatus')?.disable(); this.kycForm.get('bannedEntityMatch')?.disable(); this.kycForm.get('willfulDefaulter')?.disable(); this.kycForm.get('ecgcSalFlag')?.disable(); this.kycForm.get('dormantFlag')?.disable(); } } /** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs, * filtered by the current isMinor input for individuals). 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.getDefaultDocTypes().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, allowRiskOverride: false, 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.riskCalculationReason = ''; this.showRiskRulesTable = false; 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 (!this.kycForm.get('allowRiskOverride')?.value) { this.kycForm.get('riskCategory')?.disable({ emitEvent: false }); } if (!this.kycForm.get('allowAmlOverride')?.value) { this.kycForm.get('amlCheckStatus')?.disable({ emitEvent: false }); this.kycForm.get('bannedEntityMatch')?.disable({ emitEvent: false }); this.kycForm.get('willfulDefaulter')?.disable({ emitEvent: false }); this.kycForm.get('ecgcSalFlag')?.disable({ emitEvent: false }); this.kycForm.get('dormantFlag')?.disable({ emitEvent: false }); } } } if (changes['calculatedRisk']) { const newRisk = changes['calculatedRisk'].currentValue; if (newRisk) { this.riskCalculationReason = newRisk.reason; const allowOverride = this.kycForm.get('allowRiskOverride')?.value; const riskControl = this.kycForm.get('riskCategory'); if (!allowOverride && riskControl && riskControl.value !== newRisk.category) { riskControl.setValue(newRisk.category); riskControl.markAsDirty(); } const allowAmlOverride = this.kycForm.get('allowAmlOverride')?.value; if (!allowAmlOverride) { this.applyAutoCalculatedAmlFlags(newRisk); } } else { this.riskCalculationReason = ''; } } if (changes['constitutionMeta'] && this.isNonIndividual) { this.updateDocTypesForConstitution(); } // Legal form toggled on an already-initialized instance (route-reuse keeps this // component alive across create sessions) — the individual OVD list and the entity RBI // doc list are mutually exclusive, so re-seed from scratch rather than filtering in place. if (changes['isNonIndividual'] && !changes['isNonIndividual'].firstChange && !this.kycTemplateLoaded) { this.kycDocumentTypes = []; this.selectedKycDoc = null; this.kycForm.get('kycDocumentName')?.setValue(''); this.seedDefaultDocTypes(); if (this.isNonIndividual && this.constitutionMeta) { this.updateDocTypesForConstitution(); } } if (changes['isMinor'] && !this.isNonIndividual) { 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 { 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 { 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 an in-app preview dialog. */ viewExistingKycDoc(): void { if (!this.existingKycIdentifierId || this.existingKycDocuments.length === 0) { 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 } }), error: () => { /* silent — UI still shows the saved filename */ } }); } 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; } 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, allowRiskOverride: this.calculatedRisk ? this.calculatedRisk.category !== (data.riskCategory || 1) : false, allowAmlOverride: this.calculatedRisk ? this.calculatedRisk.amlCheckStatus !== (data.amlCheckStatus || 'CLEAR') || this.calculatedRisk.bannedEntityMatch !== (data.bannedEntityMatch || 'NO') || this.calculatedRisk.willfulDefaulter !== (data.willfulDefaulter || 'NO') || this.calculatedRisk.ecgcSalFlag !== (data.ecgcSalFlag || 'NO') || this.calculatedRisk.dormantFlag !== (data.dormantFlag || 'ACTIVE') : false, remarks: data.remarks || '', hasPan: !!data.panNumber, // AML & Compliance 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); }; // 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 // picking — otherwise a repeat upload of the same document type would keep showing // the ORIGINAL file forever, since .find() would always hit the oldest matching row. const sorted = [...identifiers].sort((a: any, b: any) => (b?.id ?? 0) - (a?.id ?? 0)); // 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()) : null; const identifier = match || sorted.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.getDefaultDocTypes().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 { 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 { 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 { 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 { 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.kycForm.getRawValue(); 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 ); } private applyAutoCalculatedAmlFlags(risk = this.calculatedRisk): void { if (!risk) return; this.kycForm.get('amlCheckStatus')?.setValue(risk.amlCheckStatus || 'CLEAR'); this.kycForm.get('bannedEntityMatch')?.setValue(risk.bannedEntityMatch || 'NO'); this.kycForm.get('willfulDefaulter')?.setValue(risk.willfulDefaulter || 'NO'); this.kycForm.get('ecgcSalFlag')?.setValue(risk.ecgcSalFlag || 'NO'); this.kycForm.get('dormantFlag')?.setValue(risk.dormantFlag || 'ACTIVE'); this.kycForm.get('amlCheckStatus')?.markAsDirty(); this.kycForm.get('bannedEntityMatch')?.markAsDirty(); this.kycForm.get('willfulDefaulter')?.markAsDirty(); this.kycForm.get('ecgcSalFlag')?.markAsDirty(); this.kycForm.get('dormantFlag')?.markAsDirty(); } } /** * PAN Error Alert Dialog */ @Component({ selector: 'mifosx-pan-error-dialog', template: `
error PAN Verification Failed

{{ data.message }}

`, 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); }