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