Files
cbsnexora/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts
T

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