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
@@ -588,95 +588,23 @@
<!-- ══ NON-INDIVIDUAL KYC ══ -->
@if (isNonIndividual) {
<!-- Incorporation Date -->
<mat-form-field appearance="outline" class="form-field" (click)="incorpDatePicker.open()">
<mat-label>Incorporation Date</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>calendar_today</mat-icon></span>
<input matInput [max]="today" [matDatepicker]="incorpDatePicker" formControlName="incorporationDate" />
<mat-datepicker-toggle matSuffix [for]="incorpDatePicker"></mat-datepicker-toggle>
<mat-datepicker #incorpDatePicker></mat-datepicker>
<!-- Date of Incorporation — captured before PAN because Enterprise PAN verification
matches the entity on name + date of incorporation as held in the PAN records. -->
<mat-form-field appearance="outline" class="form-field" (click)="incorporationDatePicker.open()">
<mat-label>Date of Incorporation</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>event_available</mat-icon></span>
<input
matInput
[max]="today"
[matDatepicker]="incorporationDatePicker"
formControlName="incorporationDate"
readonly
/>
<mat-datepicker-toggle matSuffix [for]="incorporationDatePicker"></mat-datepicker-toggle>
<mat-datepicker #incorporationDatePicker></mat-datepicker>
<mat-hint>As per Certificate of Incorporation / Registration</mat-hint>
</mat-form-field>
<!-- GSTIN (all non-individual) with Verify -->
<div class="form-field verify-field">
<mat-form-field appearance="outline" class="verify-input">
<mat-label>GSTIN Number</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>receipt_long</mat-icon></span>
<input
matInput
formControlName="gstinNumber"
placeholder="15-character GSTIN"
maxlength="15"
style="text-transform: uppercase"
(input)="
kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
}
@if (gstinVerifyStatus === 'VERIFIED') {
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
}
@if (gstinVerifyStatus === 'FAILED') {
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
}
</mat-form-field>
<div class="verify-action">
@if (gstinVerifyStatus === 'NONE' || gstinVerifyStatus === 'FAILED') {
<button
mat-raised-button
color="primary"
type="button"
class="verify-btn-inline"
[disabled]="
readOnly ||
!kycForm.get('gstinNumber')?.value ||
kycForm.get('gstinNumber')?.value.length < 15 ||
gstinVerifying ||
!clientId
"
(click)="verifyGstin()"
>
@if (gstinVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
<fa-icon icon="shield-alt"></fa-icon> Verify GSTIN
}
</button>
}
@if (gstinVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
</div>
</div>
@if (gstinVerifyStatus === 'VERIFIED') {
<div class="pan-result-card full-width">
<div class="pan-result-header">
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
<span>GSTIN Verified Successfully</span>
</div>
<div class="pan-result-body">
@if (gstinVerifyName) {
<div class="pan-result-row">
<span class="pan-result-label">Trade Name / Legal Name</span>
<span class="pan-result-value name">{{ gstinVerifyName }}</span>
</div>
}
@if (gstinVerifyState) {
<div class="pan-result-row">
<span class="pan-result-label">State</span>
<span class="pan-result-value">{{ gstinVerifyState }}</span>
</div>
}
<div class="pan-result-row">
<span class="pan-result-label">Verified On</span>
<span class="pan-result-value date">{{ gstinVerifiedOn }}</span>
</div>
</div>
</div>
}
<!-- PAN — Proprietorship uses Individual PAN, others use Enterprise PAN -->
@if (constitutionMeta?.value === 'PROPRIETORSHIP') {
<!-- Proprietorship: Individual PAN (proprietor's personal PAN) -->
@@ -826,8 +754,88 @@
}
}
<!-- CIN (Companies & LLP only) -->
@if (constitutionMeta?.showCin) {
<!-- GSTIN (all non-individual) with Verify -->
<div class="form-field verify-field">
<mat-form-field appearance="outline" class="verify-input">
<mat-label>GSTIN Number</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>receipt_long</mat-icon></span>
<input
matInput
formControlName="gstinNumber"
placeholder="15-character GSTIN"
maxlength="15"
style="text-transform: uppercase"
(input)="
kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
}
@if (gstinVerifyStatus === 'VERIFIED') {
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
}
@if (gstinVerifyStatus === 'FAILED') {
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
}
</mat-form-field>
<div class="verify-action">
@if (gstinVerifyStatus === 'NONE' || gstinVerifyStatus === 'FAILED') {
<button
mat-raised-button
color="primary"
type="button"
class="verify-btn-inline"
[disabled]="
readOnly ||
!kycForm.get('gstinNumber')?.value ||
kycForm.get('gstinNumber')?.value.length < 15 ||
gstinVerifying ||
!clientId
"
(click)="verifyGstin()"
>
@if (gstinVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
<fa-icon icon="shield-alt"></fa-icon> Verify GSTIN
}
</button>
}
@if (gstinVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
</div>
</div>
@if (gstinVerifyStatus === 'VERIFIED') {
<div class="pan-result-card full-width">
<div class="pan-result-header">
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
<span>GSTIN Verified Successfully</span>
</div>
<div class="pan-result-body">
@if (gstinVerifyName) {
<div class="pan-result-row">
<span class="pan-result-label">Trade Name / Legal Name</span>
<span class="pan-result-value name">{{ gstinVerifyName }}</span>
</div>
}
@if (gstinVerifyState) {
<div class="pan-result-row">
<span class="pan-result-label">State</span>
<span class="pan-result-value">{{ gstinVerifyState }}</span>
</div>
}
<div class="pan-result-row">
<span class="pan-result-label">Verified On</span>
<span class="pan-result-value date">{{ gstinVerifiedOn }}</span>
</div>
</div>
</div>
}
<!-- CIN -->
@if (isNonIndividual) {
<div class="form-field verify-field">
<mat-form-field appearance="outline" class="verify-input">
<mat-label>CIN (Corporate Identity Number)</mat-label>
@@ -850,7 +858,7 @@
}
</mat-form-field>
<div class="verify-action">
@if (cinVerifyStatus === 'NONE' || cinVerifyStatus === 'FAILED') {
@if (cinVerifyStatus !== 'VERIFIED') {
<button
mat-raised-button
color="primary"
@@ -862,16 +870,31 @@
@if (cinVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
<fa-icon icon="shield-alt"></fa-icon> Verify CIN
<fa-icon icon="shield-alt"></fa-icon>
{{ cinVerifyStatus === 'UNCONFIRMED' ? 'Retry' : 'Verify CIN' }}
}
</button>
}
@if (cinVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
@if (cinVerifyStatus === 'UNCONFIRMED') {
<span class="verify-chip unconfirmed"
><fa-icon icon="info-circle"></fa-icon> Saved — not confirmed</span
>
}
</div>
</div>
<!-- Accepted, but MCA returned no match. Stated plainly rather than as a failure: the
number is stored and the officer has nothing to correct. -->
@if (cinUnconfirmedMessage) {
<div class="cin-unconfirmed-note full-width">
<fa-icon icon="info-circle" class="cin-unconfirmed-note__icon"></fa-icon>
<span>{{ cinUnconfirmedMessage }}</span>
</div>
}
<!-- CIN verification progress -->
@if (cinVerifying) {
<div class="cin-progress full-width">
@@ -1429,15 +1452,19 @@
</div>
}
<!--
Overriding the risk category is the one thing that makes remarks mandatory here. A verified
record still gets the box — the officer needs somewhere to record a re-edit reason, and the
server asks for one if the save actually replaces data already on the record — but it is
offered, not demanded, so a record that auto-verified mid-wizard does not open in error.
-->
@if (kycForm.get('allowRiskOverride')?.value || isKycVerified) {
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>
{{
isKycVerified
? 'Reason for Re-edit'
: kycForm.get('allowRiskOverride')?.value
? 'Remarks (Required for Risk Override)'
: 'Remarks'
kycForm.get('allowRiskOverride')?.value
? 'Remarks (Required for Risk Override)'
: 'Reason for Re-edit (optional)'
}}
</mat-label>
<textarea
@@ -1445,18 +1472,13 @@
formControlName="remarks"
rows="2"
[placeholder]="
isKycVerified
? 'Mandatory — explain why KYC is being re-edited'
: kycForm.get('allowRiskOverride')?.value
? 'Mandatory — explain why you are overriding the auto-calculated risk category'
: ''
kycForm.get('allowRiskOverride')?.value
? 'Mandatory — explain why you are overriding the auto-calculated risk category'
: 'Optional — needed only if you change details already recorded on this verified KYC'
"
></textarea>
@if ((isKycVerified || kycForm.get('allowRiskOverride')?.value) && !kycForm.get('remarks')?.value?.trim()) {
<mat-error>
Remarks are mandatory when
{{ isKycVerified ? 'updating a verified KYC record' : 'overriding the risk category' }}
</mat-error>
@if (kycForm.get('allowRiskOverride')?.value && !kycForm.get('remarks')?.value?.trim()) {
<mat-error> Remarks are mandatory when overriding the risk category </mat-error>
}
</mat-form-field>
}
@@ -1672,77 +1694,92 @@
</div>
<!-- ═══ SECTION: Form 97/98 ═══ -->
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
Form 97 / 98
@if (!isNonIndividual) {
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
Form 97 / 98
@if (!kycForm.get('hasPan')?.value) {
<span class="kyc-badge required-badge">Required — No PAN</span>
}
</h4>
@if (!kycForm.get('hasPan')?.value) {
<span class="kyc-badge required-badge">Required — No PAN</span>
}
</h4>
@if (!kycForm.get('hasPan')?.value) {
<div class="kyc-info-hint warning-hint">
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
<span>Client does not have PAN. Form 97/98 is mandatory.</span>
</div>
<div class="form-grid">
<mat-form-field appearance="outline" class="form-field">
<mat-label>Form Type</mat-label>
<mat-select formControlName="form60Type">
<mat-option value="FORM_60">Form 97</mat-option>
<mat-option value="FORM_61">Form 98</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
<mat-label>Declaration Date</mat-label>
<input matInput [matDatepicker]="form60DatePicker" formControlName="form60Date" />
<mat-datepicker-toggle matSuffix [for]="form60DatePicker"></mat-datepicker-toggle>
<mat-datepicker #form60DatePicker></mat-datepicker>
</mat-form-field>
</div>
} @else {
<p class="form60-not-needed">Client has PAN — Form 97/98 not required.</p>
}
</div>
<!-- ═══ SECTION: Form 121 ═══ -->
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="receipt" class="section-icon"></fa-icon>
Form 121
</h4>
<div class="form-grid">
<div class="form-field checkbox-field">
<mat-checkbox formControlName="form15Applicable">Form 121 Applicable</mat-checkbox>
</div>
@if (kycForm.get('form15Applicable')?.value) {
<mat-form-field appearance="outline" class="form-field">
<mat-label>Form Type</mat-label>
<mat-select formControlName="form15Type">
<mat-option value="15G">Form 121 (Below 60 years)</mat-option>
<mat-option value="15H">Form 121 (Senior Citizen 60+)</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Financial Year</mat-label>
<input matInput formControlName="form15FY" placeholder="e.g. 2025-26" />
</mat-form-field>
<div class="kyc-info-hint warning-hint">
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
<span>Client does not have PAN. Form 97/98 is mandatory.</span>
</div>
<div class="form-grid">
<mat-form-field appearance="outline" class="form-field">
<mat-label>Form Type</mat-label>
<mat-select formControlName="form60Type">
<mat-option value="FORM_60">Form 97</mat-option>
<mat-option value="FORM_61">Form 98</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
<mat-label>Declaration Date</mat-label>
<input matInput [matDatepicker]="form60DatePicker" formControlName="form60Date" />
<mat-datepicker-toggle matSuffix [for]="form60DatePicker"></mat-datepicker-toggle>
<mat-datepicker #form60DatePicker></mat-datepicker>
</mat-form-field>
</div>
} @else {
<p class="form60-not-needed">Client has PAN — Form 97/98 not required.</p>
}
</div>
</div>
}
<!-- ═══ SECTION: Photograph & Signature ═══ -->
<!-- ═══ SECTION: Form 121 ═══ -->
@if (!isNonIndividual) {
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="receipt" class="section-icon"></fa-icon>
Form 121
</h4>
<div class="form-grid">
<div class="form-field checkbox-field">
<mat-checkbox formControlName="form15Applicable">Form 121 Applicable</mat-checkbox>
</div>
@if (kycForm.get('form15Applicable')?.value) {
<mat-form-field appearance="outline" class="form-field">
<mat-label>Form Type</mat-label>
<mat-select formControlName="form15Type">
<mat-option value="15G">Form 121 (Below 60 years)</mat-option>
<mat-option value="15H">Form 121 (Senior Citizen 60+)</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Financial Year</mat-label>
<input matInput formControlName="form15FY" placeholder="e.g. 2025-26" />
</mat-form-field>
}
</div>
</div>
}
<!-- ═══ SECTION: Photograph & Signature ═══
An entity gets the photograph box too — a shopfront, signboard or logo is what staff
recognise the client by in the ledger, and the client view has always had a photo slot in
its header with no way to fill it from the stepper. The specimen signature stays individual-
only: an entity signs through its authorised signatories, whose signatures are captured with
their own KYC rather than as one image for the company. Optional here, so a company can
still be onboarded before anyone has a usable photograph of the premises. -->
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="camera" class="section-icon"></fa-icon>
Photograph &amp; Signature
<span class="section-required-tag">Required</span>
{{ isNonIndividual ? 'Entity Photograph' : 'Photograph & Signature' }}
@if (isNonIndividual) {
<span class="section-optional-tag">Optional</span>
} @else {
<span class="section-required-tag">Required</span>
}
</h4>
<div class="photo-sig-grid">
<div class="photo-sig-grid" [class.photo-sig-grid--single]="isNonIndividual">
<div
class="upload-box"
[class.upload-box--has-preview]="photographPreview"
[class.upload-box--missing]="!photographPreview && photoSigTouched"
[class.upload-box--missing]="!photographPreview && photoSigTouched && !isNonIndividual"
>
@if (photographPreview) {
<div class="upload-preview">
@@ -1758,12 +1795,16 @@
</div>
</div>
} @else {
<div class="upload-box__icon"><fa-icon icon="user"></fa-icon></div>
<div class="upload-box__icon"><fa-icon [icon]="isNonIndividual ? 'building' : 'user'"></fa-icon></div>
<span class="upload-box__label">
Client Photograph
<span class="upload-box__req">*</span>
{{ isNonIndividual ? 'Entity Photograph / Logo' : 'Client Photograph' }}
@if (!isNonIndividual) {
<span class="upload-box__req">*</span>
}
</span>
<span class="upload-box__hint">
{{ isNonIndividual ? 'Office premises, signboard or logo — JPG / PNG, max 2MB' : 'JPG / PNG, max 2MB' }}
</span>
<span class="upload-box__hint">JPG / PNG, max 2MB</span>
<div class="upload-box__btn-row">
<label class="upload-box__btn">
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
@@ -1773,50 +1814,52 @@
<fa-icon icon="video"></fa-icon> Use Webcam
</button>
</div>
@if (photoSigTouched) {
@if (photoSigTouched && !isNonIndividual) {
<span class="upload-box__error">Photograph is required</span>
}
}
</div>
<div
class="upload-box"
[class.upload-box--has-preview]="signaturePreview"
[class.upload-box--missing]="!signaturePreview && photoSigTouched"
>
@if (signaturePreview) {
<div class="upload-preview">
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
<div class="upload-preview__actions">
<label class="upload-preview__btn recrop">
@if (!isNonIndividual) {
<div
class="upload-box"
[class.upload-box--has-preview]="signaturePreview"
[class.upload-box--missing]="!signaturePreview && photoSigTouched"
>
@if (signaturePreview) {
<div class="upload-preview">
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
<div class="upload-preview__actions">
<label class="upload-preview__btn recrop">
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
<fa-icon icon="crop"></fa-icon> Re-crop
</label>
<button type="button" class="upload-preview__btn remove" (click)="removeSignature()">
<fa-icon icon="trash"></fa-icon> Remove
</button>
</div>
</div>
} @else {
<div class="upload-box__icon"><fa-icon icon="pen"></fa-icon></div>
<span class="upload-box__label">
Specimen Signature
<span class="upload-box__req">*</span>
</span>
<span class="upload-box__hint">Scanned image, max 1MB</span>
<div class="upload-box__btn-row">
<label class="upload-box__btn">
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
<fa-icon icon="crop"></fa-icon> Re-crop
<fa-icon icon="crop"></fa-icon> Choose &amp; Crop
</label>
<button type="button" class="upload-preview__btn remove" (click)="removeSignature()">
<fa-icon icon="trash"></fa-icon> Remove
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="drawSignature()">
<fa-icon icon="pen"></fa-icon> Draw Signature
</button>
</div>
</div>
} @else {
<div class="upload-box__icon"><fa-icon icon="pen"></fa-icon></div>
<span class="upload-box__label">
Specimen Signature
<span class="upload-box__req">*</span>
</span>
<span class="upload-box__hint">Scanned image, max 1MB</span>
<div class="upload-box__btn-row">
<label class="upload-box__btn">
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
<fa-icon icon="crop"></fa-icon> Choose &amp; Crop
</label>
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="drawSignature()">
<fa-icon icon="pen"></fa-icon> Draw Signature
</button>
</div>
@if (photoSigTouched) {
<span class="upload-box__error">Signature is required</span>
@if (photoSigTouched) {
<span class="upload-box__error">Signature is required</span>
}
}
}
</div>
</div>
}
</div>
</div>
@@ -36,6 +36,20 @@
letter-spacing: 0.4px;
text-transform: uppercase;
}
/* Same chip, muted — says "you may" where the red one says "you must". */
.section-optional-tag {
margin-left: 6px;
padding: 2px 9px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
}
}
.kyc-badge {
@@ -312,6 +326,34 @@
color: #b45309;
border: 1px solid #fcd34d;
}
// Recorded but unconfirmed by the registry. Neutral blue rather than green or red: nothing failed
// and nothing passed, so it must not read as either.
&.unconfirmed {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
}
// ── CIN saved but not confirmed by MCA ─────────────────────────────────────
.cin-unconfirmed-note {
display: flex;
align-items: flex-start;
gap: 8px;
margin: -4px 0 12px;
padding: 10px 12px;
border-radius: 8px;
background: #eff6ff;
border: 1px solid #bfdbfe;
color: #1e40af;
font-size: 12.5px;
line-height: 1.45;
}
.cin-unconfirmed-note__icon {
margin-top: 1px;
flex-shrink: 0;
}
// ── PAN declaration summary ────────────────────────────────────────────────
@@ -1225,6 +1267,12 @@
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
/* Entity clients get the photograph box only, so it keeps a card's width instead of
stretching across the whole step where the signature box used to sit beside it. */
&--single {
grid-template-columns: minmax(0, 420px);
}
}
.upload-box {
@@ -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