/** * 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 { forkJoin, catchError, of } from 'rxjs'; import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule, AbstractControl, ValidationErrors } 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 { ENTITY_KYC_DOCUMENT_TYPES, KycDocumentType, withEntityKycDocuments } from '../../shared/entity-kyc-documents'; import { deriveKycExceptionFields } from '../../kyc-exception.util'; import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service'; import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component'; import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component'; import { dataUrlToFile } from 'app/shared/utils/data-url.util'; import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component'; import { PanDeclarationDialogComponent, PanDeclarationResult } from '../../shared/pan-declaration-dialog/pan-declaration-dialog.component'; import { AadhaarReceiptDialogComponent, AadhaarReceiptResult } from '../../shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component'; /** One side of the saved ID proof, as shown in its upload slot. */ interface KycDocSide { /** Id of the stored document this side came from. */ id: string; fileName: string; previewUrl: string | null; /** The image this slot shows — a half, when it was cut out of a stacked scan. */ blob: Blob | null; /** True when this side came from a single file holding both sides. */ combined: boolean; } @Component({ selector: 'mifosx-client-kyc-step', 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 = ''; /** * Name and date of birth captured on the General step. PAN and Aadhaar are matched against exactly * these two values by the provider, so they are shown here: an officer should be able to see what * the check will compare before running it, rather than discovering a mismatch only on failure. */ @Input() verificationName: string = ''; @Input() verificationDateOfBirth: Date | null = null; /** Read-only mode — disables form editing until user clicks edit icon */ @Input() readOnly = false; @Input() calculatedRisk: { category: number; reason: string; amlCheckStatus?: string; bannedEntityMatch?: string; willfulDefaulter?: string; ecgcSalFlag?: string; dormantFlag?: string; } | null = null; today = new Date(); /** * ClientKycType on the backend: 1 = INDIVIDUAL, 2 = NON_INDIVIDUAL. Every payload must carry the * value matching the client's legal form — the backend rejects the entity-only fields * (enterprisePan / gstinNumber / cinNumber / incorporationDate) on a KYC record typed INDIVIDUAL. */ get kycTypeValue(): number { return this.isNonIndividual ? 2 : 1; } /** * Risk category as it will actually be saved. * * Read via getRawValue() because the riskCategory control is disabled whenever the override * checkbox is off — which is the normal case — and `kycForm.value` omits disabled controls. * Reading `.value` there yields undefined and quietly falls back to 1 (Low), so a verification * would stamp Low onto the record and the next full save would change it to the auto-calculated * category, showing up as an edit of a verified record that the officer never made. */ private get currentRiskCategory(): number { return this.kycForm.getRawValue().riskCategory || 1; } 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: KycDocumentType[] = []; /** Chip-group state for KYC document picker (mirrors identity proof in identifier dialog) */ kycDocProofs: ProofType[] = []; kycDocGroups: { label: string; items: ProofType[] }[] = []; selectedKycDoc: ProofType | null = null; /** * Constitution whose document list is currently loaded. Distinguishes the first arrival of a * constitution from the officer switching to a different one — see updateDocTypesForConstitution. */ private appliedConstitutionValue: string | null = null; /** Front/back files for the chosen KYC document */ kycDocFiles: { front: File | null; back: File | null } = { front: null, back: null }; /** * Front and back stacked into one image, purely so the officer can eyeball both sides together. * The sides are uploaded as separate files — this render is never saved. */ kycDocMergedPreview: string | null = null; /** * 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; }[] = []; /** * The saved proof mapped onto the two upload slots, so each side shows the side it holds. * * Sides are uploaded as separate files named " — Front" / " — Back", which is what * this matches on. Clients saved before that carry one stacked "…-combined.png" holding both * sides; that image is cut back into halves so the two slots still show a front and a back rather * than the same picture twice. `blob` is whichever image the slot ends up showing — the half for * a split scan, so View opens that side alone. */ existingKycDocSides: { front: KycDocSide | null; back: KycDocSide | null; } = { front: null, back: null }; /** A stacked scan that could not be cut in two — the back slot has no image of its own to show. */ get kycDocIsLegacyCombined(): boolean { return !!this.existingKycDocSides.front?.combined && !this.existingKycDocSides.back; } /** Set true after a save attempt so we can highlight missing photo/signature boxes */ photoSigTouched = false; /** 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 { if (this.isNonIndividual) { return true; } 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', remarks: 'Reason for Re-edit' }; /** 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); } get kycAllDone(): boolean { if (this.isNonIndividual) { const needsGstin = !!this.kycForm?.get('gstinNumber')?.value; const needsCin = !!this.kycForm?.get('cinNumber')?.value; const panOk = this.entPanVerifyStatus === 'VERIFIED'; const gstinOk = !needsGstin || this.gstinVerifyStatus === 'VERIFIED'; // A CIN the provider cannot confirm still counts as done. MCA lookups return no match for // CINs that are perfectly valid, and PAN and GSTIN already establish the entity's identity — // holding the whole KYC open on a check that cannot pass would strand the client instead. const cinOk = !needsCin || this.cinVerifyStatus === 'VERIFIED' || this.cinVerifyStatus === 'UNCONFIRMED'; return panOk && gstinOk && cinOk; } else { const needsPan = !this.isMinor && this.kycForm?.get('hasPan')?.value; // A declaration settles PAN when the check cannot pass — otherwise a client whose PAN card // spells their name differently could never be created at all. const panOk = !needsPan || this.panVerifyStatus === 'VERIFIED' || this.panDeclarationTaken; // Likewise for Aadhaar: a client who has only an enrolment slip has no number to verify, so // requiring a passing OTP check would make them impossible to onboard. const aadhaarOk = this.aadhaarVerifyStatus === 'VERIFIED' || this.aadhaarReceiptTaken; return panOk && aadhaarOk; } } /** Fallback document types when template API is unavailable */ private defaultDocumentTypes = [ { name: 'Aadhaar Card', code: 'AADHAAR' }, { 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. Shown in addition to * the RBI entity documents (ENTITY_KYC_DOCUMENT_TYPES), never instead of them — see * withEntityKycDocuments. */ 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; /** * Exception state as last read back from the server. Needed to tell "this client never had an * exception" from "this client had one and has now cleared it" — only the latter should be * stamped resolved and surface in the Resolved tab. */ persistedIsException = false; persistedExceptionReason: string | null = null; /** PAN verification */ panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE'; panVerifying = false; panVerifyError: string = ''; panVerifyName: string = ''; panVerifiedOn: string = ''; /** * Declaration accepted in place of a passing PAN check. * * The Income Tax check matches on name *and* date of birth, so a client whose PAN card carries a * different spelling can never clear it however many times the officer retries. Holding a signed * declaration that records what differs is what lets the client be created and what waives the * PAN KYC exception — PAN itself stays unverified, which is the honest state. */ panDeclaration: PanDeclarationResult | null = null; /** Date the declaration was accepted, in the tenant's display format. */ panDeclarationTakenOn = ''; /** Message from the failed verification — carried into the declaration dialog as its reason. */ private panDeclarationFailureMessage = ''; /** True when the failure was not a name/DOB mismatch (invalid PAN, vendor unreachable). */ private panDeclarationUnverifiable = false; get panDeclarationTaken(): boolean { return !!this.panDeclaration; } /** "Name", "Date of Birth", "Name and Date of Birth", or the not-checked-at-all case. */ get panDeclarationMismatchLabel(): string { const fields = this.panDeclaration?.mismatchFields; if (!fields || fields === 'NONE') { return 'PAN could not be verified'; } return fields .split(',') .map((f) => (f === 'NAME' ? 'Name' : 'Date of Birth')) .join(' and '); } /** Aadhaar verification */ aadhaarVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE'; aadhaarVerifying = false; aadhaarVerifyError: string = ''; aadhaarVerifyName: string = ''; aadhaarVisible = false; aadhaarOtpSent = false; aadhaarOtp = ''; aadhaarOtpVerifying = false; aadhaarOtpReferenceId = ''; /** * UIDAI enrolment receipt accepted in place of a verified Aadhaar. * * A customer who has applied for Aadhaar but has not been issued one holds only an enrolment * slip. With no Aadhaar number there is nothing to send to the OTP flow, so the check can never * pass — recording the slip is what lets the client be created and waives the Aadhaar exception. */ aadhaarReceipt: AadhaarReceiptResult | null = null; /** Date the receipt was accepted, in the tenant's display format. */ aadhaarReceiptTakenOn = ''; get aadhaarReceiptTaken(): boolean { return !!this.aadhaarReceipt; } /** OTP digit boxes */ otpDigits: string[] = [ '', '', '', '', '', '' ]; /** 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) */ /** * UNCONFIRMED means the CIN is saved and correctly formatted, but MCA (via LincPay) would not * confirm it. Kept distinct from VERIFIED, which is only ever set by a positive answer from the * provider, and from FAILED, which is reserved for the provider being unreachable. */ cinVerifyStatus: 'NONE' | 'VERIFIED' | 'UNCONFIRMED' | 'FAILED' = 'NONE'; /** Explanation shown beside the CIN when the provider could not confirm it. */ cinUnconfirmedMessage = ''; 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 = ''; /** * Cross-field check for the OVD "Document Number" field (kycDocumentNumber): when Aadhaar Card * is the selected KYC document, its value must identify the same Aadhaar as the aadhaarNumber * field captured earlier in this step - the last 4 digits are what's compared, since * aadhaarNumber may hold either the raw 12-digit number or a masked value (masking only ever * obscures the first 8 digits - see aadhaarMasked in the kycData getter below - so the last 4 * characters are always the real digits either way). Not applicable to any other document type, * where kycDocumentNumber is unrelated to Aadhaar entirely. */ private aadhaarLast4MatchValidator = (control: AbstractControl): ValidationErrors | null => { if (this.selectedKycDoc?.code !== 'AADHAAR') { return null; } const docNumber = (control.value || '').toString().trim(); const aadhaarValue = (control.parent?.get('aadhaarNumber')?.value || '').toString().trim(); if (docNumber.length < 4 || aadhaarValue.length < 4) { // Required/pattern validators already flag an empty or too-short value on their own. return null; } return docNumber.slice(-4) === aadhaarValue.slice(-4) ? null : { aadhaarLast4Mismatch: true }; }; /** 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}|[X•*\s\d]{12,14})$/), Validators.maxLength(14) ] ], 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), this.aadhaarLast4MatchValidator ] ], kycDocIssueDate: [''], kycDocExpiryDate: [''], riskCategory: [1], allowRiskOverride: [false], allowAmlOverride: [false], remarks: [''], isException: [false], exceptionReason: [[]], amlCheckStatus: [ 'CLEAR', Validators.required ], bannedEntityMatch: [ 'NO', Validators.required ], willfulDefaulter: ['NO'], ecgcSalFlag: ['NO'], dormantFlag: ['ACTIVE'], accountStatus: ['ACTIVE'], freezeReason: [''], freezeAuthority: [''], freezeDate: [''], freezeAmount: [''], unfreezeDate: [''], noDebitDate: [''], 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 ? ENTITY_KYC_DOCUMENT_TYPES : 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(); } this.kycForm.get('remarks')?.setValidators(Validators.required); } else { riskControl?.disable(); if (this.calculatedRisk) { riskControl?.setValue(this.calculatedRisk.category); } // Overriding the risk category is the only thing this form demands a justification for. // A verified record does not, by itself, need one — see loadExistingKycIfNeeded. this.kycForm.get('remarks')?.clearValidators(); } this.kycForm.get('remarks')?.updateValueAndValidity(); }); 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(); } // aadhaarLast4MatchValidator (on kycDocumentNumber) reads this control's value directly rather // than through its own valueChanges, so a re-check has to be triggered explicitly whenever the // Aadhaar field itself changes - otherwise fixing aadhaarNumber after kycDocumentNumber was // already entered would leave a stale mismatch error in place. this.kycForm.get('aadhaarNumber')?.valueChanges.subscribe(() => { this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true }); }); } /** 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', isException: false, exceptionReason: [] }); this.persistedIsException = false; this.persistedExceptionReason = null; 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.kycDocMergedPreview = null; this.existingKycIdentifierId = null; this.riskCalculationReason = ''; this.showRiskRulesTable = false; this.existingKycDocuments = []; this.existingKycDocSides = { front: null, back: null }; this.photoSigTouched = false; this.panVerifyStatus = 'NONE'; this.panVerifying = false; this.panVerifyError = ''; this.panVerifyName = ''; this.panVerifiedOn = ''; this.panDeclaration = null; this.panDeclarationTakenOn = ''; this.panDeclarationFailureMessage = ''; this.panDeclarationUnverifiable = false; this.aadhaarVerifyStatus = 'NONE'; this.aadhaarVerifying = false; this.aadhaarVerifyError = ''; this.aadhaarVerifyName = ''; this.aadhaarReceipt = null; this.aadhaarReceiptTakenOn = ''; this.aadhaarVisible = false; this.aadhaarOtpSent = false; this.resetOtpDigits(); 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.cinUnconfirmedMessage = ''; 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]) { // The RBI entity documents stay at the head of the list — picking a constitution adds that // constitution's own requirements, it does not swap out the set the officer was just shown. this.kycDocumentTypes = withEntityKycDocuments( this.constitutionDocTypes[constitutionValue], this.constitutionMeta?.label || null ); // Clearing the chosen document type only makes sense when the officer switches constitution, // which genuinely invalidates it. It must NOT happen the first time a constitution arrives: // on edit that happens after the saved KYC has been restored, so wiping the value here would // discard the restored document type — and with it the upload slots that display the file // already held against the client, since they render only once a type is selected. const constitutionChanged = this.appliedConstitutionValue !== null && this.appliedConstitutionValue !== constitutionValue; this.appliedConstitutionValue = constitutionValue; if (constitutionChanged) { this.kycForm.get('kycDocumentName')?.setValue(''); this.kycForm.get('kycDocumentName')?.updateValueAndValidity(); } // Re-resolves the selected chip against the new list from whatever name is on the form. 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, // Constitution-specific documents carry no code PROOF_META knows, so their group label // has to be handed over explicitly or they all collect under "Other". category: d.category }) ); 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.findKycDocProofByName(currentName) : null; } /** * Resolves a stored document-type name back to a chip in the picker. * * A tolerant match is required because the two names are not written by the same thing: an * identifier row carries the CodeValue label the backend holds ("Certificate of Incorporation") * while the picker shows the fuller, constitution-specific label ("Certificate of Incorporation / * Registration Certificate"), so one is usually contained in the other rather than equal to it. * An exact comparison leaves the chip unselected on edit, which in turn hides the file-upload * slots and therefore the document the officer already uploaded. */ private findKycDocProofByName(name: string | null | undefined): ProofType | null { const clean = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); const target = clean(name || ''); if (!target) { return null; } const proofs = this.kycDocProofs || []; const exact = proofs.find((p) => clean(p.name) === target); if (exact) { return exact; } // Closest containment wins, so a short label cannot claim a chip that a nearer one also matches. const contained = proofs .filter((p) => { const candidate = clean(p.name); return candidate.includes(target) || target.includes(candidate); }) .sort((a, b) => Math.abs(clean(a.name).length - target.length) - Math.abs(clean(b.name).length - target.length)); return contained[0] || 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.kycDocMergedPreview = null; // aadhaarLast4MatchValidator only applies while Aadhaar Card is selected - re-check so switching // to/away from it immediately picks up (or clears) the mismatch error. this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true }); } 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.kycDocMergedPreview = null; } /** Open the saved file for one side of the KYC ID proof in an in-app preview dialog. */ viewExistingKycDoc(slot: 'front' | 'back' = 'front'): void { const side = this.existingKycDocSides[slot]; if (!side) { return; } const show = (blob: Blob) => this.dialog.open(DocumentPreviewDialogComponent, { data: { blob, title: side.fileName, fileName: side.fileName } }); // A side cut out of a stacked scan is only held here, so show that half rather than // re-downloading the file with both sides on it. if (side.blob) { show(side.blob); return; } if (!this.existingKycIdentifierId) { return; } this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId, side.id).subscribe({ next: show, error: () => { /* silent — UI still shows the saved filename */ } }); } /** Builds the both-sides-at-once preview. The merged image is shown, never uploaded. */ private async refreshKycDocMerged(): Promise { const proof = this.selectedKycDoc; if (!proof || proof.sides !== 2 || !this.kycDocFiles.front || !this.kycDocFiles.back) { this.kycDocMergedPreview = null; return; } const merged = await this.kycUpload.mergeFrontBack( this.kycDocFiles.front, this.kycDocFiles.back, proof.name || 'kyc-document' ); this.kycDocMergedPreview = merged ? merged.preview : null; } /** * Files + proof metadata for the parent to persist as a client identifier. * * Each side is sent as its own file, named by slot. They used to be stacked into a single * combined PNG, which left one image standing for two slots — so re-opening the client showed * the same front-and-back picture under both "Front side" and "Back side". */ get kycDocPayload(): { proof: ProofType | null; proofId: any; documentKey: string; files: { slot: string; file: File }[]; } { const files: { slot: string; file: File }[] = []; 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 || '', freezeDate: parseDate(data.freezeDate), freezeAmount: data.freezeAmount ?? '', unfreezeDate: parseDate(data.unfreezeDate), noDebitDate: parseDate(data.noDebitDate), // Form 60 / Form 15 form60Type: data.form60Type || 'FORM_60', form60Date: parseDate(data.form60Date), form15Applicable: data.form15Applicable || false, form15Type: data.form15Type || '15G', form15FY: data.form15FY || '', isException: data.isException || false, exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : [] }); // Remarks are NOT forced just because the record is verified. The server asks for them // only when a save actually overwrites data the verified record already held // (ClientKycWritePlatformServiceJpaRepositoryImpl.updateKyc), and it says so in the error // it returns. Demanding them up front made every non-individual client open with a red // "Reason for Re-edit" the moment PAN and GSTIN auto-verified — before the officer had // changed anything, and while they still had the KYC document fields left to fill in. this.persistedIsException = data.isException === true; this.persistedExceptionReason = data.exceptionReason || null; // Sync chip-picker selection with the prefilled document name if (data.kycDocumentName) { this.selectedKycDoc = this.findKycDocProofByName(data.kycDocumentName); // patchValue above ran kycDocumentNumber's validators before selectedKycDoc was known, // so aadhaarLast4MatchValidator needs a fresh check now that it is. this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true }); } // Set verification statuses from existing data if (data.panVerified) { if (this.isNonIndividual) { this.entPanVerifyStatus = 'VERIFIED'; } else { this.panVerifyStatus = 'VERIFIED'; } this.panVerifyName = data.panFullName || data.panHolderName || ''; const rawDate = data.panVerifiedOn; if (Array.isArray(rawDate) && rawDate.length >= 3) { this.panVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`; } else if (rawDate) { this.panVerifiedOn = rawDate; } } // A PAN that verified needs no declaration; one that did not may already carry one, and // the officer has to see it — it is the only reason PAN shows no exception. if (!data.panVerified && data.panDeclarationTaken === true) { this.panVerifyStatus = 'FAILED'; this.panDeclaration = { mismatchFields: data.panDeclarationMismatchFields || 'NONE', nameOnPan: data.panDeclarationNameOnPan || null, dobOnPan: this.parseApiDate(data.panDeclarationDobOnPan), // The scan itself lives with the client's documents; the record keeps its name. file: null, fileName: data.panDeclarationRemarks || '' }; this.panDeclarationTakenOn = this.formatApiDate(data.panDeclarationTakenOn); this.panDeclarationUnverifiable = this.panDeclaration.mismatchFields === 'NONE'; } if (data.aadhaarVerified) { this.aadhaarVerifyStatus = 'VERIFIED'; this.aadhaarVerifyName = data.aadhaarHolderName || 'Aadhaar Verified'; } else if (data.aadhaarReceiptTaken === true) { // Aadhaar is unverified but an enrolment slip is on file — the officer has to see it, // since it is the only reason Aadhaar carries no exception. this.aadhaarReceipt = { enrolmentId: data.aadhaarEnrolmentId || '', enrolmentDate: this.parseApiDate(data.aadhaarEnrolmentDate), remarks: data.aadhaarReceiptRemarks || '' }; this.aadhaarReceiptTakenOn = this.formatApiDate(data.aadhaarReceiptTakenOn); } if (data.panAadhaarSeedingStatus) { this.panAadhaarSeedingStatus = data.panAadhaarSeedingStatus; } 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; } } if (data.cinVerified) { this.cinVerifyStatus = 'VERIFIED'; // The verified card shows this; without it a reopened client renders the panel blank. this.cinCompanyName = data.cinCompanyName || 'Verified via MCA'; const rawCinDate = data.cinVerifiedOn; if (Array.isArray(rawCinDate) && rawCinDate.length >= 3) { this.cinVerifiedOn = `${rawCinDate[2]?.toString().padStart(2, '0')}/${rawCinDate[1]?.toString().padStart(2, '0')}/${rawCinDate[0]}`; } else if (rawCinDate) { this.cinVerifiedOn = rawCinDate; } } } // Load existing photo & signature this.loadExistingPhoto(); 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 m_client_kyc row yet (e.g. CERSAI sync hasn't run) — that's fine, but the client // can still have a KYC-type identifier already on file (uploaded via the CKYC tab, // which writes straight to client_identifier and never touches this record). Look it // up the same way the success branch does, just without a document-name hint. this.kycLoaded = true; this.loadExistingPhoto(); this.loadExistingSignature(); this.loadExistingKycIdentifier(undefined); } }); } /** * 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; forkJoin({ identifiers: this.clientsService.getClientIdentifiers(this.clientId), // Only needed to spot the dual-purpose OVD types (Aadhaar/Passport/Voter ID/Driving // Licence) below — a failure here just means that disambiguation step is skipped. proofTypes: this.clientsService.getKycProofTypes().pipe(catchError(() => of({ addressProofs: [] }))) }).subscribe({ next: ({ identifiers, proofTypes }: { identifiers: any[]; proofTypes: any }) => { if (!identifiers || identifiers.length === 0) return; const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); const isKycType = (i: any) => { const n = cleanStr(i?.documentType?.name); return ( !!n && this.kycDocProofs.some((p) => { const pname = cleanStr(p.name); return pname === n || n.includes(pname) || pname.includes(n); }) ); }; // Aadhaar, Passport, Voter ID and Driving Licence are valid OVDs for BOTH this slot and // the separate address-proof one (client-india-address-step) — a client with, say, a PAN // identity proof and an Aadhaar address proof has two client_identifier rows that both // pass isKycType. Without a name to go on, prefer whichever candidate is NOT also an // address-proof type, so this slot doesn't grab the other step's file. const addressTypeNames = new Set((proofTypes?.addressProofs || []).map((p: any) => cleanStr(p?.name))); const isAddressType = (i: any) => addressTypeNames.has(cleanStr(i?.documentType?.name)); // 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) => { const name = cleanStr(i?.documentType?.name); const kName = cleanStr(kycDocumentName); return name === kName || name.includes(kName) || kName.includes(name); }) : null; // Last resort, when EVERY kyc-type candidate is also address-eligible (e.g. a client // whose identity proof and address proof are both Aadhaar/Passport/Voter ID/Driving // Licence): this stepper always runs before the address step in both the create and // edit wizards, so the identity proof is the one that was on file first — pick the // OLDEST match here, and the address step (client-india-address-step) picks the NEWEST // among ITS own address-eligible candidates, so the two steps land on different rows // without needing to coordinate at runtime. const identifier = match || sorted.find((i) => isKycType(i) && !isAddressType(i)) || [...sorted].reverse().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) { const proof = this.findKycDocProofByName(identifier.documentType.name); this.selectedKycDoc = proof; // Store the picker's own label rather than the identifier's when one matched, so the // selected chip and the form value agree and a later save round-trips the same name. this.kycForm.patchValue({ kycDocumentName: proof?.name || identifier.documentType.name, kycDocumentNumber: identifier.documentKey || '' }); } // Same for issue/expiry date — the CKYC tab (and client-india-address-step, for address // proof) write "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" into the identifier's // description; read it back here only to fill in whatever the KYC record itself doesn't // already have, same as the documentKey backfill above. const desc: string = identifier?.description || ''; const issueMatch = desc.match(/Issue date:\s*([^,|]+)/i); const expiryMatch = desc.match(/Expiry date:\s*([^,|]+)/i); if (issueMatch && !this.kycForm.get('kycDocIssueDate')?.value) { this.kycForm.patchValue({ kycDocIssueDate: this.parseApiDate(issueMatch[1].trim()) }); } if (expiryMatch && !this.kycForm.get('kycDocExpiryDate')?.value) { this.kycForm.patchValue({ kycDocExpiryDate: this.parseApiDate(expiryMatch[1].trim()) }); } 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 })); this.mapExistingKycDocsToSides(); }, error: () => {} }); }, error: () => {} }); } /** * Works out which saved file belongs to which side, then fetches a thumbnail for each. * * Uploads are named " — Front" / " — Back", so the side is read off the document * name and only falls back to upload order when the name says nothing. A file that announces * itself as combined is handed to the splitter, since it holds both sides in one image. */ private mapExistingKycDocsToSides(): void { const sideOf = (doc: { name: string; fileName: string }): 'front' | 'back' | 'combined' | null => { const text = `${doc.name || ''} ${doc.fileName || ''}`.toLowerCase(); if (text.includes('combined')) return 'combined'; if (text.includes('back')) return 'back'; if (text.includes('front')) return 'front'; return null; }; const entry = (doc: { id: string; name: string; fileName: string }, combined: boolean): KycDocSide => ({ id: doc.id, fileName: doc.fileName || doc.name, previewUrl: null, blob: null, combined }); const sides: { front: KycDocSide | null; back: KycDocSide | null } = { front: null, back: null }; const unlabelled: typeof this.existingKycDocuments = []; for (const doc of this.existingKycDocuments) { const side = sideOf(doc); if (side === 'combined') { sides.front = sides.front || entry(doc, true); } else if (side === 'front') { sides.front = entry(doc, false); } else if (side === 'back') { sides.back = entry(doc, false); } else { unlabelled.push(doc); } } // Nothing in the names to go on — fall back to upload order, front first. for (const doc of unlabelled) { if (!sides.front) { sides.front = entry(doc, false); } else if (!sides.back) { sides.back = entry(doc, false); } } this.existingKycDocSides = sides; if (sides.front?.combined && !sides.back) { this.splitCombinedKycDoc(sides.front); return; } for (const key of [ 'front', 'back' ] as const) { const side = sides[key]; if (side && this.isImageKycDoc(side.id)) { this.loadKycSidePreview(side); } } } private isImageKycDoc(id: string): boolean { const doc = this.existingKycDocuments.find((d) => d.id === id); if (!doc) { return false; } const mime = (doc.type || '').toLowerCase(); const name = (doc.fileName || '').toLowerCase(); return mime.startsWith('image/') || name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png'); } private loadKycSidePreview(side: KycDocSide): void { this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId!, side.id).subscribe({ next: (blob: Blob) => { side.blob = blob; side.previewUrl = URL.createObjectURL(blob); const doc = this.existingKycDocuments.find((d) => d.id === side.id); if (doc) { doc.previewUrl = side.previewUrl; } }, error: () => {} }); } /** * Cuts a saved front-above-back scan back into the two sides it was made from. * * Clients saved before the sides were stored separately have one stacked image and no back file, * which is why both slots used to show the same picture. The seam left by the merge is what makes * the split possible; when it cannot be found the front slot keeps the whole scan and the back * slot says where its side is, rather than repeating the image and implying a back scan exists. */ private splitCombinedKycDoc(front: KycDocSide): void { if (!this.isImageKycDoc(front.id)) { return; } const base = front.fileName; this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId!, front.id).subscribe({ next: (blob: Blob) => { this.kycUpload.splitFrontBack(blob).then((split) => { if (!split) { front.blob = blob; front.previewUrl = URL.createObjectURL(blob); return; } front.fileName = `${base} (front)`; front.blob = split.front; front.previewUrl = URL.createObjectURL(split.front); this.existingKycDocSides = { front, back: { id: front.id, fileName: `${base} (back)`, previewUrl: URL.createObjectURL(split.back), blob: split.back, combined: true } }; }); }, error: () => {} }); } /** Fetch existing client profile photo */ private loadExistingPhoto(): void { if (!this.clientId) return; 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) { const templateDocs = template.requiredDocuments.map((doc: any) => ({ name: doc.name, code: doc.code || doc.name, mandatory: doc.mandatory ?? false })); // The backend template names the same entity documents differently ("Certificate of // Incorporation" / "COI", "PAN Card of Company" / "PAN"), so for an entity it is merged // onto the RBI list rather than replacing it — otherwise reopening a non-individual // client for edit shows a different set of chips than the one used to create it. this.kycDocumentTypes = this.isNonIndividual ? withEntityKycDocuments(templateDocs, template?.clientTypeName || this.constitutionMeta?.label || null) : this.filterDocTypesForMinor(templateDocs); // 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; // An entity keeps whatever constitution-specific documents it already has alongside the // RBI list; a failed template call must not quietly shorten the picker. const constitutionDocs = this.isNonIndividual ? this.constitutionDocTypes[this.constitutionMeta?.value] || [] : []; this.kycDocumentTypes = this.isNonIndividual ? withEntityKycDocuments(constitutionDocs, this.constitutionMeta?.label || null) : 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) { // riskCategory is dropped alongside kycType on an update. The verify payloads only carry it to // satisfy the initiate validator, which requires it when the record is first created; once the // row exists, re-asserting it on every PAN/GSTIN/CIN check means a verification silently // rewrites the risk category. On a record that has auto-verified that reads as an edit of // verified data and is refused, blocking the verification itself. The officer's risk choice is // persisted by the KYC step's own save, which does not come through here. const { kycType, riskCategory, ...updatePayload } = payload; // If KYC is VERIFIED, changing data already on the record needs remarks — use re-edit command. // Whether this particular save actually replaces stored data is something only the server can // tell (it holds the current values), so it decides: a non-individual record auto-verifies as // soon as PAN and GSTIN pass, and the officer must still be able to fill in the KYC document // fields that are empty at that point. Remarks are forwarded when present and the server // rejects the save if they were required. if (this.isKycVerified) { const remarks = this.kycForm.get('remarks')?.value?.trim(); if (remarks) { 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 { if (this.kycForm.get('allowRiskOverride')?.value && !this.kycForm.get('remarks')?.value?.trim()) { onError('Remarks are mandatory when overriding the risk category.'); this.kycForm.get('remarks')?.markAsTouched(); return; } // Normal update for non-verified records this.clientsService.updateClientKyc(this.clientId, this.kycId, updatePayload).subscribe({ next: () => callback(), error: (err: any) => onError(err?.error?.defaultUserMessage || 'Failed to update KYC.') }); } } else { if (this.kycForm.get('allowRiskOverride')?.value && !this.kycForm.get('remarks')?.value?.trim()) { onError('Remarks are mandatory when overriding the risk category.'); this.kycForm.get('remarks')?.markAsTouched(); return; } // First time — use POST to create this.clientsService.saveClientKyc(this.clientId, payload).subscribe({ next: (res: any) => { 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: this.kycTypeValue, riskCategory: this.currentRiskCategory, panNumber: pan, aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000' }; // A proprietorship reaches this method (it verifies the proprietor's individual PAN) but is // still legalForm=ENTITY to the backend, which matches an entity on name + date of // incorporation. Persist that date with this save so verifyPAN() can read it off the record. // Never sent for individuals — the backend rejects entity-only fields on an individual KYC. if (this.isNonIndividual) { const incorpDate = this.kycForm.value.incorporationDate; const dateFormat = this.settingsService.dateFormat; panPayload.dateFormat = dateFormat; panPayload.incorporationDate = incorpDate instanceof Date ? this.dateUtils.formatDate(incorpDate, dateFormat) : incorpDate || null; if (!panPayload.incorporationDate) { this.panVerifying = false; this.panVerifyError = 'Enter the Date of Incorporation before verifying the PAN.'; this.kycForm.get('incorporationDate')?.markAsTouched(); return; } } 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.handlePanFailure( "Client name or date of birth does not match the PAN card holder. Please ensure the client's name and date of birth exactly match the PAN card details.", false ); return; } this.panVerifyStatus = 'VERIFIED'; // A PAN that now verifies makes any earlier declaration moot — drop it so the record // does not keep claiming a mismatch that no longer exists. this.clearPanDeclaration(); this.panVerifyName = resolvedName; // Date — could be string or array [y,m,d] const rawDate = res.changes?.panVerifiedOn; 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.handlePanFailure(res?.changes?.panVerificationMessage || 'PAN verification failed.', true); } }, error: (err: any) => { this.panVerifying = false; this.panVerifyStatus = 'FAILED'; const msg = err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'PAN verification failed.'; // The backend reports a name/DOB mismatch through a dedicated error, so a message // naming it is treated as the comparable case even though it arrives on the error path. this.handlePanFailure(msg, !this.isNameOrDobMismatchMessage(msg)); } }); }, (msg) => { this.panVerifying = false; this.showPanErrorAlert(msg); } ); } /** Does this failure message describe a name/DOB mismatch, as opposed to any other failure? */ private isNameOrDobMismatchMessage(message: string): boolean { const lower = (message || '').toLowerCase(); return lower.includes('does not match') || lower.includes('name.mismatch') || lower.includes('name mismatch'); } /** * Shows why PAN verification failed and offers the declaration route out of it. * * Every PAN failure is offered a declaration, not only the mismatch case: an officer facing an * unreachable vendor is just as stuck as one facing a misspelt PAN card, and both are resolved by * the same signed paperwork. `unverifiable` only changes what the dialog asks for — there is no * point comparing against a PAN card when the check never reached the Income Tax records. */ private handlePanFailure(message: string, unverifiable: boolean): void { this.panDeclarationFailureMessage = message; this.panDeclarationUnverifiable = unverifiable; this.panVerifyError = ''; this.dialog .open(PanErrorDialogComponent, { width: '480px', data: { message, offerDeclaration: true } }) .afterClosed() .subscribe((takeDeclaration: boolean) => { if (takeDeclaration) { this.openPanDeclaration(); } }); } /** * Opens the declaration dialog and, on save, persists it with the KYC record. * * The declaration is written straight through rather than held until the next Save & Next: it is * what suppresses the PAN exception, and an officer who takes a declaration and then navigates * away must not find the client sitting in the exception list. */ openPanDeclaration(): void { if (!this.clientId) { this.showPanErrorAlert('Client must be created first. Complete General tab and click Next.'); return; } const dateFormat = this.settingsService.dateFormat; this.dialog .open(PanDeclarationDialogComponent, { width: '720px', maxWidth: '95vw', disableClose: true, data: { submittedName: this.verificationName, submittedDob: this.verificationDateOfBirth ? this.dateUtils.formatDate(this.verificationDateOfBirth, dateFormat) : '', panNumber: (this.kycForm.value.panNumber || '').toUpperCase().trim(), failureMessage: this.panDeclarationFailureMessage || 'PAN verification failed.', unverifiable: this.panDeclarationUnverifiable, existing: this.panDeclaration } }) .afterClosed() .subscribe((result: PanDeclarationResult | undefined) => { if (!result) { return; } // The scan has to reach the client's documents before the KYC record claims a declaration // is on file — otherwise the exception is waived by a document nobody can produce. if (result.file) { this.uploadPanDeclarationFile( result.file, (msg) => this.showPanErrorAlert(msg), () => this.storePanDeclaration(result, dateFormat) ); } else { this.storePanDeclaration(result, dateFormat); } }); } /** Files the signed declaration against the client, so the waiver has its proof attached. */ private uploadPanDeclarationFile(file: File, onError: (msg: string) => void, onDone: () => void): void { const formData = new FormData(); formData.append('file', file); formData.append('name', 'PAN Declaration'); formData.append( 'description', `Signed PAN declaration for ${(this.kycForm.value.panNumber || '').toUpperCase().trim() || 'PAN'}` ); this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({ next: () => onDone(), error: (err: any) => onError( err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not upload the signed declaration. Please try again.' ) }); } private storePanDeclaration(result: PanDeclarationResult, dateFormat: string): void { this.panDeclaration = result; this.panDeclarationTakenOn = this.dateUtils.formatDate(new Date(), dateFormat); this.saveKycAndDo( this.kycData, () => { /* stored — the KYC record now carries the declaration and no PAN exception */ }, (msg) => { // Roll the declaration back so the UI never shows one the server did not accept. this.clearPanDeclaration(); this.showPanErrorAlert(msg); } ); } /** * Fineract serialises dates as `[yyyy, M, d]`, but the same field comes back as a formatted * string from the command-response path — both shapes have to be accepted. */ private parseApiDate(raw: any): Date | null { if (!raw) { return null; } if (Array.isArray(raw) && raw.length >= 3) { return new Date(raw[0], raw[1] - 1, raw[2]); } const parsed = new Date(raw); return isNaN(parsed.getTime()) ? null : parsed; } /** The same value rendered for display in the tenant's date format. */ private formatApiDate(raw: any): string { const parsed = this.parseApiDate(raw); return parsed ? this.dateUtils.formatDate(parsed, this.settingsService.dateFormat) : ''; } /** Drops the declaration, both in the UI and from the next payload written to the server. */ private clearPanDeclaration(): void { this.panDeclaration = null; this.panDeclarationTakenOn = ''; } /** Officer-initiated removal — e.g. the declaration was taken against the wrong PAN. */ removePanDeclaration(): void { if (!this.panDeclaration) { return; } this.clearPanDeclaration(); if (this.clientId && this.kycId) { this.saveKycAndDo( this.kycData, () => { /* removed — the PAN exception is raised again on this same save */ }, (msg) => this.showPanErrorAlert(msg) ); } } /** * Verify Enterprise PAN (non-individual) — same flow as individual PAN verification. * Maps: Company PAN → panNumber, Company Name → client name, Date of Incorporation → DOB */ 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. // For an entity the provider's "DOB" is the date of incorporation, so it must be persisted on the // KYC record by this same save — verifyPAN() below reads it back off the record, not off the form. const payload: any = { locale: 'en', kycType: this.kycTypeValue, riskCategory: this.currentRiskCategory, panNumber: pan, // Company PAN → panNumber enterprisePan: pan, incorporationDate: incorpDate instanceof Date ? this.dateUtils.formatDate(incorpDate, dateFormat) : incorpDate, dateFormat, aadhaarNumberMasked: 'XXXXXXXX0000' // Placeholder — not applicable for entities }; if (!payload.incorporationDate) { this.entPanVerifying = false; this.showPanErrorAlert('Enter the Date of Incorporation before verifying the Enterprise PAN.'); this.kycForm.get('incorporationDate')?.markAsTouched(); return; } 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()}`; } // Seeding status — mirrors verifyPan(), so the value the provider returned is held // here too and the next save round-trips it instead of leaving this screen blank. if (res.changes?.panAadhaarSeedingStatus) { this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus; } } 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: this.kycTypeValue, riskCategory: this.currentRiskCategory, 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; } if (!this.clientId) { this.showPanErrorAlert('Client must be created first. Complete General tab and click Next.'); return; } this.cinVerifying = true; this.startCinProgress(); // The CIN has to be on the record before the command runs: the backend reads it off the KYC row // (and rejects with "CIN number is not set on this KYC record" otherwise) rather than taking it // from the request. Same save-then-verify order as PAN and GSTIN. const payload: any = { locale: 'en', kycType: this.kycTypeValue, riskCategory: this.currentRiskCategory, cinNumber: cin, dateFormat: this.settingsService.dateFormat, panNumber: this.kycForm.value.enterprisePan || this.kycForm.value.panNumber || null, aadhaarNumberMasked: 'XXXXXXXX0000' }; this.saveKycAndDo( payload, () => { this.clientsService.verifyCIN(this.clientId, this.kycId).subscribe({ next: (res: any) => { this.stopCinProgress(); const changes = res?.changes || res; if (changes?.cinVerified === true) { this.cinVerifyStatus = 'VERIFIED'; // The label distinguishes a CIN the registry actually matched from one accepted on // its structure alone, so the card never claims an MCA confirmation that never came. this.cinCompanyName = changes?.cinCompanyName || (changes?.cinConfirmedByRegistry === false ? 'Format validated' : 'Verified via MCA'); this.cinUnconfirmedMessage = changes?.cinConfirmedByRegistry === false ? changes?.cinVerificationMessage || '' : ''; const rawDate = changes?.cinVerifiedOn; if (Array.isArray(rawDate)) { this.cinVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`; } else { const now = new Date(); this.cinVerifiedOn = rawDate || `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`; } } else { this.markCinUnconfirmed(changes?.cinVerificationMessage); } }, error: (err: any) => { this.stopCinProgress(); const message = err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'CIN verification failed.'; // A 503 means the provider could not be reached, which is worth interrupting the officer // for — it is likely to succeed on a retry. Anything else is the provider having // answered without confirming the company, which is not something a retry fixes and not // something the officer can correct, so it is reported in place instead of as an alert. if (err?.status === 503) { this.cinVerifyStatus = 'FAILED'; this.showPanErrorAlert(message); } else { this.markCinUnconfirmed(message); } } }); }, (msg) => { this.stopCinProgress(); this.showPanErrorAlert(msg); } ); } /** * The CIN is stored and well-formed, but MCA did not confirm it. * * Reported in place rather than as an error dialog because there is nothing for the officer to * correct: the number is already saved and the provider simply has no record to match it against. * Deliberately NOT recorded as verified — `cinVerified` stays false on the KYC record, so the * distinction between a confirmed and an unconfirmed CIN survives into the client's history. */ private markCinUnconfirmed(message?: string): void { this.cinVerifyStatus = 'UNCONFIRMED'; this.cinUnconfirmedMessage = message || 'MCA could not confirm this CIN. The number has been saved against the client.'; } /** * Drives the stepped MCA progress display while the verification is in flight. Deliberately stops * short of the last step and of 100%: the real outcome comes from the server, and letting the bar * finish on its own is what used to make an unverified CIN look verified. */ private startCinProgress(): void { if (this.cinProgressTimer) { clearInterval(this.cinProgressTimer); } 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), 90); if (elapsed >= totalDuration) { clearInterval(this.cinProgressTimer); this.cinProgressTimer = null; } }, interval); } private stopCinProgress(): void { if (this.cinProgressTimer) { clearInterval(this.cinProgressTimer); this.cinProgressTimer = null; } this.cinProgressValue = 100; this.cinVerifying = false; } /** 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: this.kycTypeValue, riskCategory: this.currentRiskCategory, 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; // Aadhaar has been issued and verified, so the enrolment slip has served its purpose — // drop it rather than leave the record claiming the customer is still waiting. this.clearAadhaarReceipt(); if (res.changes?.panAadhaarSeedingStatus) { this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus; } } else { this.aadhaarVerifyError = res?.changes?.aadhaarVerificationMessage || 'OTP verification failed'; } }, error: (err: any) => { this.aadhaarOtpVerifying = false; this.aadhaarVerifyError = err?.error?.defaultUserMessage || 'OTP verification failed.'; } }); } /** * Opens the enrolment-receipt dialog and, on save, persists it with the KYC record. * * Written straight through rather than held until the next Save & Next: the receipt is what * suppresses the Aadhaar exception, and an officer who records one and then navigates away must * not find the client sitting in the exception list. */ openAadhaarReceipt(): void { if (!this.clientId) { this.aadhaarVerifyError = 'Client must be created first. Complete General tab and click Next.'; return; } const dateFormat = this.settingsService.dateFormat; this.dialog .open(AadhaarReceiptDialogComponent, { width: '560px', maxWidth: '95vw', disableClose: true, data: { clientName: this.verificationName, reason: this.aadhaarVerifyError || 'Aadhaar has not been verified. Record the enrolment slip if the customer has applied but not yet received their Aadhaar.', existing: this.aadhaarReceipt } }) .afterClosed() .subscribe((result: AadhaarReceiptResult | undefined) => { if (!result) { return; } this.aadhaarReceipt = result; this.aadhaarReceiptTakenOn = this.dateUtils.formatDate(new Date(), dateFormat); this.aadhaarVerifyError = ''; this.saveKycAndDo( this.kycData, () => { /* stored — the KYC record now carries the receipt and no Aadhaar exception */ }, (msg) => { // Roll back so the UI never shows a receipt the server did not accept. this.clearAadhaarReceipt(); this.aadhaarVerifyError = msg; } ); }); } /** Drops the receipt, both in the UI and from the next payload written to the server. */ private clearAadhaarReceipt(): void { this.aadhaarReceipt = null; this.aadhaarReceiptTakenOn = ''; } /** Officer-initiated removal — the Aadhaar exception is raised again on this same save. */ removeAadhaarReceipt(): void { if (!this.aadhaarReceipt) { return; } this.clearAadhaarReceipt(); if (this.clientId && this.kycId) { this.saveKycAndDo( this.kycData, () => { /* removed */ }, (msg) => { this.aadhaarVerifyError = msg; } ); } } private showPanErrorAlert(message: string) { this.panVerifyError = ''; this.dialog.open(PanErrorDialogComponent, { width: '480px', data: { message } }); } getRenewalYears(): number { switch (this.currentRiskCategory) { 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); } 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.applySignatureFile(result.croppedFile, result.previewUrl); } }); } /** Draw-with-mouse/touchpad entry point for the specimen signature — same staging/upload * path as the file-picker and webcam flows, minus the crop step (the pad's canvas is * already the right shape/size). */ drawSignature(): void { const dialogRef = this.dialog.open(DrawSignatureDialogComponent); dialogRef.afterClosed().subscribe((file: File) => { if (file) { this.applySignatureFile(file, URL.createObjectURL(file)); } }); } /** Stages the signature locally and, in edit mode, persists it — replacing any existing * signature doc first so a client never ends up with two. Shared by the file-picker, webcam * and draw-pad entry points. */ private applySignatureFile(file: File, previewUrl: string): void { this.signatureFile = file; this.signaturePreview = previewUrl; if (!this.clientId) { return; } if (this.existingSignatureDocId) { this.clientsService.deleteClientDocument(this.clientId, this.existingSignatureDocId).subscribe({ next: () => this.uploadSignatureFile(file), error: () => this.uploadSignatureFile(file) }); } else { this.uploadSignatureFile(file); } } private uploadSignatureFile(file: File): void { this.clientsService.uploadClientSignatureImage(this.clientId, file).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: this.kycTypeValue, 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, // Only ever sent when this screen actually holds a value. The seeding status is derived by // the provider during PAN verification and written straight to the record, so sending an // empty one back would blank a field the server filled in — which reads as an officer // editing a verified record rather than the no-op it is. ...(this.panAadhaarSeedingStatus ? { panAadhaarSeedingStatus: this.panAadhaarSeedingStatus } : {}), // PAN declaration — stored so the waived exception can be justified later, and so a reload // does not silently drop the paperwork that is holding the exception off. panDeclarationTaken: this.panDeclarationTaken, panDeclarationMismatchFields: this.panDeclaration?.mismatchFields || null, panDeclarationNameOnPan: this.panDeclaration?.nameOnPan || null, panDeclarationDobOnPan: formatDate(this.panDeclaration?.dobOnPan || null), // Names the uploaded declaration filed under the client's documents. panDeclarationRemarks: this.panDeclaration?.fileName || null, panDeclarationTakenOn: this.panDeclarationTakenOn || null, // Aadhaar enrolment receipt — same reasoning as the PAN declaration above. aadhaarReceiptTaken: this.aadhaarReceiptTaken, aadhaarEnrolmentId: this.aadhaarReceipt?.enrolmentId || null, aadhaarEnrolmentDate: formatDate(this.aadhaarReceipt?.enrolmentDate || null), aadhaarReceiptRemarks: this.aadhaarReceipt?.remarks || null, aadhaarReceiptTakenOn: this.aadhaarReceiptTakenOn || null, gstinNumber: gstinNorm, enterprisePan: enterprisePanNorm, cinNumber: cinNorm, 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, freezeDate: formatDate(v.freezeDate), freezeAmount: v.freezeAmount === '' || v.freezeAmount === null || v.freezeAmount === undefined ? null : v.freezeAmount, unfreezeDate: formatDate(v.unfreezeDate), noDebitDate: formatDate(v.noDebitDate), // Form 60 / Form 15 form60Type: v.form60Type || null, form60Date: formatDate(v.form60Date), form15Applicable: v.form15Applicable || false, form15Type: v.form15Type || null, form15FY: v.form15FY || null, remarks: v.remarks || null, // Exception state is DERIVED from verification status, not from a checkbox — an unverified // or blank PAN/Aadhaar is an exception by definition, so the officer cannot forget to flag // it. See deriveKycExceptionFields() for the raise / narrow / close rules. ...deriveKycExceptionFields({ panVerified: this.isNonIndividual ? this.entPanVerifyStatus === 'VERIFIED' : this.panVerifyStatus === 'VERIFIED', // A signed declaration closes the PAN item by paperwork when the API never can. panDeclarationTaken: this.panDeclarationTaken, aadhaarVerified: this.aadhaarVerifyStatus === 'VERIFIED', // An enrolment slip closes the Aadhaar item when there is no number to verify yet. aadhaarReceiptTaken: this.aadhaarReceiptTaken, // Non-individual clients submit a dummy masked Aadhaar, so checking it would saddle every // company with a permanent exception. aadhaarApplicable: !this.isNonIndividual, hadException: this.persistedIsException, previousReason: this.persistedExceptionReason, manualReasons: Array.isArray(v.exceptionReason) ? v.exceptionReason : [], todayFormatted: this.dateUtils.formatDate(new Date(), dateFormat) }) }; } 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.incorporationDate || v.kycDocumentName || v.form15Applicable || v.isException ); } 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 }}

@if (data.offerDeclaration) {

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

}
@if (data.offerDeclaration) { } @else { }
`, 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-hint { margin: 12px 0 0; padding-top: 12px; border-top: 1px solid #e2e8f0; font-size: 13px; color: #64748b; } .pan-err-actions { padding: 10px 24px 16px !important; margin: 0 !important; gap: 8px; } ` ], imports: [ MatIcon, MatDialogContent, MatDialogActions, MatDialogClose, STANDALONE_SHARED_IMPORTS ] }) export class PanErrorDialogComponent { /** * `offerDeclaration` turns the dead-end error into a fork: retry, or record the customer * declaration that stands in for a check the client can never pass. Closes with `true` when the * officer chooses the declaration. */ data = inject<{ message: string; offerDeclaration?: boolean }>(MAT_DIALOG_DATA); }