feat(clients, alm): add CIN/MCA verification & authorized signatories tab; refactor ALM report print and export
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s

This commit is contained in:
2026-08-13 15:05:16 +05:30
parent 75df8865dd
commit 5581d4180d
29 changed files with 3002 additions and 2453 deletions
@@ -29,6 +29,7 @@ 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';
@@ -110,6 +111,27 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
} | 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;
@@ -121,12 +143,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
}
/** KYC Document Types — populated from API template, falls back to defaults */
kycDocumentTypes: { name: string; code: string; mandatory: boolean }[] = [];
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 };
/**
@@ -185,6 +212,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Photo + signature are mandatory before leaving the KYC tab */
get photoSigComplete(): boolean {
if (this.isNonIndividual) {
return true;
}
return this.hasPhoto && this.hasSignature;
}
@@ -225,7 +255,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
const needsCin = !!this.kycForm?.get('cinNumber')?.value;
const panOk = this.entPanVerifyStatus === 'VERIFIED';
const gstinOk = !needsGstin || this.gstinVerifyStatus === 'VERIFIED';
const cinOk = !needsCin || this.cinVerifyStatus === '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;
@@ -250,18 +283,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
{ name: 'Birth Certificate', code: 'BIRTH_CERTIFICATE' },
{ name: 'School ID Card', code: 'SCHOOL_ID_CARD' }
];
/** Generic RBI-prescribed KYC documents for legal entities (Master Direction on KYC,
* Annexure I) — shown for non-individual clients before a specific constitution type is
* picked. Individual OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/NPR) don't apply to entities. */
private nonIndividualDefaultDocTypes = [
{ name: 'Certificate of Incorporation / Registration Certificate', code: 'CERT_INCORPORATION' },
{ name: 'PAN Card of Entity', code: 'ENTITY_PAN' },
{ name: 'Memorandum & Articles of Association / Partnership Deed / Trust Deed', code: 'CONSTITUTIVE_DOC' },
{ name: 'Board Resolution / Authorization Letter', code: 'BOARD_RESOLUTION' },
{ name: 'KYC of Authorized Signatory / Power of Attorney Holder', code: 'AUTH_SIGNATORY_KYC' },
{ name: 'GST Registration Certificate', code: 'GST_CERTIFICATE' }
];
/** Constitution-based KYC document types for non-individual clients */
/** 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 },
@@ -513,7 +537,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
gstinVerifyState = '';
/** CIN verification (dummy) */
cinVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
/**
* 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 = '';
@@ -648,9 +679,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Individual-only OVDs vs. entity-only RBI docs — never both, and minor filtering only
* ever applies to individuals. */
private getDefaultDocTypes(): { name: string; code: string }[] {
return this.isNonIndividual
? this.nonIndividualDefaultDocTypes
: this.filterDocTypesForMinor(this.defaultDocumentTypes);
return this.isNonIndividual ? ENTITY_KYC_DOCUMENT_TYPES : this.filterDocTypesForMinor(this.defaultDocumentTypes);
}
ngOnInit(): void {
@@ -673,9 +702,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
if (this.calculatedRisk) {
riskControl?.setValue(this.calculatedRisk.category);
}
if (!this.isKycVerified) {
this.kycForm.get('remarks')?.clearValidators();
}
// 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();
});
@@ -797,6 +826,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.gstinVerifiedOn = '';
this.gstinVerifyState = '';
this.cinVerifyStatus = 'NONE';
this.cinUnconfirmedMessage = '';
this.cinVerifying = false;
this.cinVerifiedOn = '';
this.cinCompanyName = '';
@@ -898,14 +928,26 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
private updateDocTypesForConstitution(): void {
const constitutionValue = this.constitutionMeta?.value;
if (constitutionValue && this.constitutionDocTypes[constitutionValue]) {
this.kycDocumentTypes = this.constitutionDocTypes[constitutionValue].map((doc) => ({
name: doc.name,
code: doc.name,
mandatory: doc.mandatory
}));
// Reset selected document type since options changed
this.kycForm.get('kycDocumentName')?.setValue('');
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
// 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();
}
}
@@ -913,18 +955,51 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** 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 })
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;
if (currentName) {
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const targetName = cleanStr(currentName);
this.selectedKycDoc = this.kycDocProofs.find((p) => cleanStr(p.name) === targetName) || null;
} else {
this.selectedKycDoc = null;
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 {
@@ -1102,19 +1177,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
});
// Set remarks as required if KYC is verified
if (this.isKycVerified) {
this.kycForm.get('remarks')?.setValidators(Validators.required);
this.kycForm.get('remarks')?.updateValueAndValidity();
this.kycForm.get('remarks')?.markAsTouched(); // Force red underline so user knows it's blocking
}
// 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) {
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const targetName = cleanStr(data.kycDocumentName);
this.selectedKycDoc = this.kycDocProofs.find((p) => cleanStr(p.name) === targetName) || null;
this.selectedKycDoc = this.findKycDocProofByName(data.kycDocumentName);
}
// Set verification statuses from existing data
if (data.panVerified) {
@@ -1175,6 +1248,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
}
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]}`;
@@ -1243,11 +1318,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// 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: identifier.documentType.name,
kycDocumentName: proof?.name || identifier.documentType.name,
kycDocumentNumber: identifier.documentKey || ''
});
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === identifier.documentType.name) || null;
}
this.clientsService.getClientIdentificationDocuments(this.existingKycIdentifierId).subscribe({
@@ -1453,13 +1531,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycTemplate = template;
this.kycTemplateLoaded = true;
if (template?.requiredDocuments?.length > 0) {
this.kycDocumentTypes = this.filterDocTypesForMinor(
template.requiredDocuments.map((doc: any) => ({
name: doc.name,
code: doc.code || doc.name,
mandatory: doc.mandatory ?? false
}))
);
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) {
@@ -1472,11 +1555,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
error: () => {
// Fallback to defaults if template API unavailable
this.kycTemplateLoaded = true;
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}));
// 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();
}
});
@@ -1485,17 +1575,25 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Save or update KYC, then execute callback */
private saveKycAndDo(payload: any, callback: () => void, onError: (msg: string) => void) {
if (this.kycId) {
const { kycType, ...updatePayload } = payload;
// 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, remarks are mandatory — use re-edit command
// 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) {
onError('Remarks are mandatory when updating a verified KYC record.');
this.kycForm.get('remarks')?.markAsTouched();
return;
if (remarks) {
updatePayload.remarks = remarks;
}
updatePayload.remarks = remarks;
this.clientsService.reEditClientKyc(this.clientId, this.kycId, updatePayload).subscribe({
next: () => {
// Status transitions to IN_PROGRESS, PAN verification is invalidated
@@ -1571,12 +1669,30 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
const panPayload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
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,
() => {
@@ -1834,16 +1950,27 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
const dateFormat = this.settingsService.dateFormat;
const incorpDate = this.kycForm.value.incorporationDate;
// Same payload structure as individual — backend uses panNumber + client name/DOB for verification
// 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: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
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,
() => {
@@ -1862,6 +1989,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
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(
@@ -1907,8 +2039,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
const payload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
kycType: this.kycTypeValue,
riskCategory: this.currentRiskCategory,
gstinNumber: gstin,
dateFormat,
panNumber: this.kycForm.value.enterprisePan || this.kycForm.value.panNumber || null,
@@ -1976,7 +2108,105 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
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;
@@ -2000,22 +2230,23 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
break;
}
}
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 100);
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 90);
if (elapsed >= totalDuration) {
clearInterval(this.cinProgressTimer);
this.cinCurrentStep = stepDurations.length;
setTimeout(() => {
this.cinVerifying = false;
this.cinVerifyStatus = 'VERIFIED';
this.cinCompanyName = 'Verified via MCA';
const now = new Date();
this.cinVerifiedOn = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
}, 300);
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) {
@@ -2041,8 +2272,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
const aadhaarMasked = 'XXXXXXXX' + aadhaar.slice(-4);
const aadhaarPayload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
kycType: this.kycTypeValue,
riskCategory: this.currentRiskCategory,
aadhaarNumberMasked: aadhaarMasked
};
const panVal = this.kycForm.value.panNumber;
@@ -2203,7 +2434,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
}
getRenewalYears(): number {
switch (this.kycForm.value.riskCategory) {
switch (this.currentRiskCategory) {
case 1:
return 10;
case 2:
@@ -2387,12 +2618,16 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
return {
locale: 'en',
dateFormat,
kycType: 1,
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,
panAadhaarSeedingStatus: this.panAadhaarSeedingStatus || null,
// 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,
@@ -2470,6 +2705,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
v.gstinNumber ||
v.enterprisePan ||
v.cinNumber ||
v.incorporationDate ||
v.kycDocumentName ||
v.form15Applicable ||
v.isException