2900 lines
117 KiB
TypeScript
2900 lines
117 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 { 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 "<Proof> — Front" / "<Proof> — 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<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);
|
|
}
|
|
|
|
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<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 = '';
|
|
|
|
/**
|
|
* 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<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 ? 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<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.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<void> {
|
|
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<string>((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 "<Proof> — Front" / "<Proof> — 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<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: 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: `
|
|
<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>
|
|
@if (data.offerDeclaration) {
|
|
<p class="pan-err-hint">
|
|
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.
|
|
</p>
|
|
}
|
|
</mat-dialog-content>
|
|
<mat-dialog-actions align="end" class="pan-err-actions">
|
|
@if (data.offerDeclaration) {
|
|
<button mat-stroked-button [mat-dialog-close]="false">Close</button>
|
|
<button mat-flat-button color="primary" [mat-dialog-close]="true">Take Declaration</button>
|
|
} @else {
|
|
<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-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);
|
|
}
|