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
@@ -136,7 +136,14 @@
| translate
}}
</mat-label>
<input matInput required formControlName="fullname" autocomplete="off" />
<input
matInput
required
formControlName="fullname"
autocomplete="off"
(blur)="capitalizeControl('fullname')"
style="text-transform: capitalize"
/>
@if (createClientForm.controls.fullname.hasError('required')) {
<mat-error>Client name is <strong>required</strong></mat-error>
} @else if (createClientForm.controls.fullname.hasError('pattern')) {
@@ -151,7 +158,9 @@
<div class="full-name-row full-width">
<div class="full-name-preview">
<span class="full-name-label">Entity Name:</span>
<span class="full-name-value">{{ createClientForm.get('fullname')?.value }}</span>
<span class="full-name-value" style="text-transform: capitalize">{{
createClientForm.get('fullname')?.value
}}</span>
</div>
<div class="verify-inline">
@if (!sanctionsVerified && !sanctionsBlocked) {
@@ -214,7 +223,14 @@
<mat-form-field appearance="outline" class="name-field first-name">
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>badge</mat-icon></span>
<input matInput required formControlName="firstname" autocomplete="off" />
<input
matInput
required
formControlName="firstname"
autocomplete="off"
(blur)="capitalizeControl('firstname')"
style="text-transform: capitalize"
/>
@if (createClientForm.controls.firstname.hasError('required')) {
<mat-error>First name is <strong>required</strong></mat-error>
} @else if (createClientForm.controls.firstname.hasError('pattern')) {
@@ -228,7 +244,13 @@
<mat-form-field appearance="outline" class="name-field middle-name">
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>person_outline</mat-icon></span>
<input matInput formControlName="middlename" autocomplete="off" />
<input
matInput
formControlName="middlename"
autocomplete="off"
(blur)="capitalizeControl('middlename')"
style="text-transform: capitalize"
/>
@if (
createClientForm.controls.middlename?.hasError('pattern') &&
createClientForm.controls.middlename?.touched
@@ -241,7 +263,14 @@
<mat-form-field appearance="outline" class="name-field last-name">
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span>
<input matInput required formControlName="lastname" autocomplete="off" />
<input
matInput
required
formControlName="lastname"
autocomplete="off"
(blur)="capitalizeControl('lastname')"
style="text-transform: capitalize"
/>
@if (createClientForm.controls.lastname.hasError('required')) {
<mat-error>Last name is <strong>required</strong></mat-error>
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
@@ -694,6 +723,9 @@
}
</mat-form-field>
<!-- Date of Incorporation is captured on the KYC step, next to the entity PAN it is
verified against — see client-kyc-step's `incorporationDate` control. -->
<!-- Authorized Signatories (multiple, registered clients) -->
<div class="form-field full-width signatory-section">
<h4 class="section-title signatory-title">
@@ -1148,6 +1180,79 @@
</div>
}
<!--
Section: Turnover & Risk (Non-Individual only)
An entity has no salary to declare, so the individual Financial & Classification section below
never applied to it — which left a company with no turnover on file at all, and its risk stuck
on the flat entity default. Turnover is the size signal the branch actually has at onboarding,
so it is captured here and the risk band it produces is shown right beside it: the officer sees
the grade move as they type, instead of first meeting it on the KYC tab.
-->
@if (createClientForm.get('legalFormId')?.value === LegalFormId.ENTITY) {
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="chart-line" class="section-icon"></fa-icon>
Turnover &amp; Risk Profile
</h4>
<div class="form-grid">
<mat-form-field appearance="outline" class="form-field">
<mat-label>Monthly Turnover</mat-label>
<span matPrefix class="rupee-prefix">&#8377;</span>
<input matInput type="number" formControlName="monthlyTurnover" min="0" />
<mat-hint>Yearly turnover fills in automatically (× 12)</mat-hint>
@if (
createClientForm.get('monthlyTurnover')?.hasError('pattern') &&
createClientForm.get('monthlyTurnover')?.touched
) {
<mat-error>Enter a valid amount</mat-error>
} @else if (
createClientForm.get('monthlyTurnover')?.hasError('min') && createClientForm.get('monthlyTurnover')?.touched
) {
<mat-error>Amount cannot be negative</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Annual Turnover</mat-label>
<span matPrefix class="rupee-prefix">&#8377;</span>
<input matInput type="number" formControlName="annualTurnover" min="0" />
<mat-hint>Drives the risk grade below</mat-hint>
@if (
createClientForm.get('annualTurnover')?.hasError('pattern') &&
createClientForm.get('annualTurnover')?.touched
) {
<mat-error>Enter a valid amount</mat-error>
} @else if (
createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched
) {
<mat-error>Amount cannot be negative</mat-error>
}
</mat-form-field>
<div
class="full-width entity-risk-card"
[class.entity-risk-card--low]="entityTurnoverRisk.category === 1"
[class.entity-risk-card--medium]="entityTurnoverRisk.category === 2"
[class.entity-risk-card--high]="entityTurnoverRisk.category === 3"
>
<div class="entity-risk-card__head">
<fa-icon icon="shield-alt" class="entity-risk-card__icon"></fa-icon>
<span class="entity-risk-card__title">Auto-calculated Risk Category</span>
<span class="entity-risk-card__badge">{{ riskCategoryLabel(entityTurnoverRisk.category) }}</span>
</div>
<p class="entity-risk-card__reason">{{ entityTurnoverRisk.reason }}</p>
<p class="entity-risk-card__scale">
Up to ₹1 crore — Low &nbsp;·&nbsp; ₹1 crore to ₹10 crore — Medium &nbsp;·&nbsp; Above ₹10 crore — High
</p>
<p class="entity-risk-card__note">
Carried into the KYC tab, where it can still be overridden with a recorded reason.
</p>
</div>
</div>
</div>
}
<!-- Section: Financial & Classification (Individual only) -->
@if (createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
<div class="section-block">
@@ -56,6 +56,103 @@ form {
grid-column: 1 / -1;
}
/* Live risk grade beside the entity's turnover fields. Neutral by default; the accent tracks the
band so a jump from Medium to High is visible without reading the sentence. */
.entity-risk-card {
margin: 4px 0 12px;
padding: 12px 14px;
border-radius: 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-left: 4px solid #94a3b8;
&__head {
display: flex;
align-items: center;
gap: 8px;
}
&__icon {
color: #64748b;
}
&__title {
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
&__badge {
margin-left: auto;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.3px;
text-transform: uppercase;
background: #e2e8f0;
color: #334155;
}
&__reason {
margin: 8px 0 0;
font-size: 13px;
color: #334155;
}
&__scale,
&__note {
margin: 4px 0 0;
font-size: 11px;
color: #64748b;
}
&--low {
background: #f0fdf4;
border-color: #bbf7d0;
border-left-color: #16a34a;
.entity-risk-card__icon {
color: #16a34a;
}
.entity-risk-card__badge {
background: #dcfce7;
color: #15803d;
}
}
&--medium {
background: #fffbeb;
border-color: #fde68a;
border-left-color: #d97706;
.entity-risk-card__icon {
color: #d97706;
}
.entity-risk-card__badge {
background: #fef3c7;
color: #b45309;
}
}
&--high {
background: #fef2f2;
border-color: #fecaca;
border-left-color: #dc2626;
.entity-risk-card__icon {
color: #dc2626;
}
.entity-risk-card__badge {
background: #fee2e2;
color: #b91c1c;
}
}
}
.senior-citizen-auto-note {
display: flex;
align-items: center;
@@ -32,6 +32,7 @@ import { filter, switchMap, takeUntil } from 'rxjs/operators';
import { ClientsService } from 'app/clients/clients.service';
import { Dates } from 'app/core/utils/dates';
import { LegalFormId } from 'app/clients/models/legal-form.enum';
import { toProperNameCase } from 'app/clients/utils/name-case.util';
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
/** Custom Services */
@@ -654,6 +655,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
if (this.createClientForm.get('guardianDetails')) {
this.createClientForm.removeControl('guardianDetails');
}
// Same reasoning as the guardian above: the reused component instance would otherwise carry the
// previous entity's authorized signatories into a brand new client.
this.signatories = [];
this.signatorySearchText = '';
this.signatorySearchResults = [];
// Verification state must not carry over — a previous client's cleared sanctions
// screening or verified OTP must not silently apply to a different new client.
this.mobileVerified = false;
@@ -777,8 +783,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
incorpNumber: data.clientNonPersonDetails.incorpNumber || '',
remarks: data.clientNonPersonDetails.remarks || '',
authorizedSignatory: data.clientNonPersonDetails.authorizedSignatory || '',
authorizedSignatoryClientIds: data.clientNonPersonDetails.authorizedSignatoryClientIds || '',
additionalInfo: data.clientNonPersonDetails.additionalInfo || ''
});
this.restoreSignatories(
data.clientNonPersonDetails.authorizedSignatoryClientIds,
data.clientNonPersonDetails.authorizedSignatory
);
}
}
@@ -971,7 +982,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
/**
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
* and annual income for annual/yearly turnover).
* and annual income for annual/yearly turnover), but only if the turnover fields
* have not been manually modified or cleared.
*/
private linkIncomeToTurnover(): void {
const monthlyIncome = this.createClientForm.get('monthlyIncome');
@@ -979,23 +991,33 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
const annualIncome = this.createClientForm.get('annualIncome');
const annualTurnover = this.createClientForm.get('annualTurnover');
if (monthlyIncome && monthlyTurnover) {
const isEmpty = (val: any) => val === '' || val === null || val === undefined;
if (monthlyIncome && monthlyTurnover && annualTurnover) {
monthlyIncome.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
const isPristine = monthlyTurnover.pristine && annualTurnover.pristine;
const isTurnoverEmpty = isEmpty(monthlyTurnover.value) && isEmpty(annualTurnover.value);
if (isPristine || isTurnoverEmpty) {
const num = parseFloat(value);
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
}
});
}
if (annualIncome && annualTurnover) {
if (annualIncome && annualTurnover && monthlyTurnover) {
annualIncome.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
const isPristine = monthlyTurnover.pristine && annualTurnover.pristine;
const isTurnoverEmpty = isEmpty(monthlyTurnover.value) && isEmpty(annualTurnover.value);
if (isPristine || isTurnoverEmpty) {
const num = parseFloat(value);
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
}
});
}
}
@@ -1122,6 +1144,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
mainBusinessLineId: [''],
incorpNumber: [''],
authorizedSignatory: [''],
// Client ids behind the names above, in the same order — what lets an edit rebuild the
// signatory rows with their client links and PAN/DIN state. No input is bound to it;
// syncSignatoryNames() keeps it in step with the chips.
authorizedSignatoryClientIds: [''],
additionalInfo: [''],
remarks: ['']
})
@@ -1343,6 +1369,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.removeGuardian();
}
capitalizeControl(controlName: string) {
const control = this.createClientForm.get(controlName);
if (control && control.value) {
const originalValue = control.value;
const capitalized = toProperNameCase(originalValue);
if (originalValue !== capitalized) {
control.setValue(capitalized);
}
}
}
/**
* Verify client name against sanctions lists via dialog.
* Passes the client name to the dialog for real API screening.
@@ -1467,6 +1504,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
target.mobileNo = data.mobileNo || null;
target.emailAddress = data.emailAddress || null;
target.officeName = data.officeName || null;
// Filled in only when the caller has not supplied them. A signatory restored from stored
// ids starts as a bare { id } and needs both to render its row; one picked from the search
// dropdown already carries them, and a guardian must keep whatever it was given.
target.displayName = target.displayName || data.displayName;
target.status = target.status || data.status;
target._loading = false;
},
error: () => {
@@ -1480,6 +1522,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
target.panVerified = kyc?.panVerified || false;
target.aadhaarMasked = kyc?.aadhaarNumberMasked || '-';
target.aadhaarVerified = kyc?.aadhaarVerified || false;
// Same reasoning as above: a DIN verified in an earlier session lives on the signatory's own
// KYC record, so re-reading it here is what stops the edit screen asking for it again.
target.dinNumber = target.dinNumber || kyc?.dinNumber || '';
target.dinVerified = target.dinVerified || kyc?.dinVerified || false;
},
error: () => {
target.panNumber = '-';
@@ -1678,9 +1724,49 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
});
}
/**
* Rebuilds the authorized-signatory rows when an existing entity client is opened for edit.
*
* Driven by the stored client ids rather than the stored names: the ids are what make each row a
* real client again, so its CIF, PAN and DIN verification can be re-read instead of the officer
* having to re-add and re-verify signatories that were already recorded. The saved names are used
* only as the label until the lookup returns, and as the fallback for a client that can no longer
* be fetched, so a row is never silently dropped.
*
* Clients created before the ids were stored have names but no ids; those still list, as
* name-only rows, rather than showing nothing at all.
*/
private restoreSignatories(storedIds: string | null | undefined, storedNames: string | null | undefined): void {
const ids = (storedIds || '')
.split(',')
.map((id: string) => id.trim())
.filter(Boolean);
const names = (storedNames || '')
.split(',')
.map((name: string) => name.trim())
.filter(Boolean);
if (!ids.length) {
this.signatories = names.map((displayName: string) => ({ displayName }));
return;
}
this.signatories = ids.map((id: string, index: number) => ({
id: Number(id),
displayName: names[index] || '',
_loading: true
}));
this.signatories.forEach((sig: any) => this.fetchClientFullDetails(sig.id, sig));
}
private syncSignatoryNames(): void {
const names = this.signatories.map((s) => s.displayName).join(', ');
this.createClientForm.get('clientNonPersonDetails')?.patchValue({ authorizedSignatory: names });
// Ids are written in the same order as the names, so the two columns line up on reload.
const clientIds = this.signatories.map((s) => s.id).join(',');
this.createClientForm.get('clientNonPersonDetails')?.patchValue({
authorizedSignatory: names,
authorizedSignatoryClientIds: clientIds
});
}
/** Watch MSME turnover & investment for auto-classification */
@@ -2164,6 +2250,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
delete generalDetails[key];
}
}
// Entity name is normalised here as well as on blur: a pasted/auto-filled value, or one the
// user never blurred out of before submitting, would otherwise reach Fineract exactly as typed.
if (generalDetails.fullname) {
generalDetails.fullname = toProperNameCase(generalDetails.fullname);
}
if (generalDetails.submittedOnDate instanceof Date) {
generalDetails.submittedOnDate = this.dateUtils.formatDate(generalDetails.submittedOnDate, dateFormat);
}
@@ -2233,15 +2325,86 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
];
unsupportedFields.forEach((f) => delete generalDetails[f]);
// Format dateOfRegistration if present
if (generalDetails.clientNonPersonDetails?.dateOfRegistration instanceof Date) {
generalDetails.clientNonPersonDetails.dateOfRegistration = this.dateUtils.formatDate(
generalDetails.clientNonPersonDetails.dateOfRegistration,
dateFormat
);
return generalDetails;
}
/**
* Turnover ladder that decides an entity's risk when no configured risk rule matches it.
*
* A company's size is the one thing about it the branch always knows at onboarding, and the
* bigger the book the more exposure it carries — so the band moves up with annual turnover
* instead of every entity landing on a flat Medium regardless of whether it turns over ten lakh
* or a hundred crore. Thresholds follow the MSME-style split the business already thinks in
* (crores), and are only a fallback: a rule configured in m_client_risk_rule with
* minAnnualTurnover / maxAnnualTurnover still wins, which is where a bank sets its own numbers.
*
* Amounts are in rupees. `upTo: null` is the open-ended top band.
*/
private static readonly ENTITY_TURNOVER_RISK_BANDS: { upTo: number | null; category: number; label: string }[] = [
{ upTo: 10000000, category: 1, label: 'up to ₹1 crore' },
{ upTo: 100000000, category: 2, label: '₹1 crore to ₹10 crore' },
{ upTo: null, category: 3, label: 'above ₹10 crore' }
];
/** "Low Risk" / "Medium Risk" / "High Risk" for a category id. */
riskCategoryLabel(category: number): string {
if (category === 3) {
return 'High Risk';
}
return category === 2 ? 'Medium Risk' : 'Low Risk';
}
/**
* Entity risk from annual turnover, for the fallback path only.
*
* AML is deliberately left CLEAR even in the top band. A configured High-risk rule defaults to
* FLAGGED because someone decided that case warrants it; turnover on its own is a size signal,
* not a suspicion, and auto-flagging every large company would put them all in the AML queue.
*/
private entityRiskFromTurnover(annualTurnover: number): {
category: number;
reason: string;
amlCheckStatus: string;
bannedEntityMatch: string;
willfulDefaulter: string;
ecgcSalFlag: string;
dormantFlag: string;
} {
const base = {
amlCheckStatus: 'CLEAR',
bannedEntityMatch: 'NO',
willfulDefaulter: 'NO',
ecgcSalFlag: 'NO',
dormantFlag: 'ACTIVE'
};
// Nothing captured yet — the entity default stands until a turnover is entered.
if (!annualTurnover || annualTurnover <= 0) {
return {
category: 2,
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
...base
};
}
return generalDetails;
const band =
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS.find((b) => b.upTo == null || annualTurnover <= b.upTo) ??
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS[
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS.length - 1
];
return {
category: band.category,
reason: `Annual turnover ₹${annualTurnover.toLocaleString('en-IN')} — ${band.label} (${this.riskCategoryLabel(
band.category
)})`,
...base
};
}
/** Live risk band for the entity turnover card in the General step. */
get entityTurnoverRisk(): { category: number; reason: string } {
return this.calculatedRisk;
}
get calculatedRisk(): {
@@ -2338,15 +2501,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// Default Fallbacks
if (legalFormId !== LegalFormId.PERSON) {
return {
category: 2,
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
amlCheckStatus: 'CLEAR',
bannedEntityMatch: 'NO',
willfulDefaulter: 'NO',
ecgcSalFlag: 'NO',
dormantFlag: 'ACTIVE'
};
return this.entityRiskFromTurnover(annualTurnover);
}
return {
category: 1,
@@ -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
@@ -442,18 +442,18 @@
<span class="preview-value mono">{{ kycData.enterprisePan }}</span>
</div>
}
@if (kycData.incorporationDate) {
<div class="preview-field">
<span class="preview-label">Date of Incorporation</span>
<span class="preview-value">{{ kycData.incorporationDate }}</span>
</div>
}
@if (kycData.cinNumber) {
<div class="preview-field">
<span class="preview-label">CIN (Corporate Identity Number)</span>
<span class="preview-value mono">{{ kycData.cinNumber }}</span>
</div>
}
@if (kycData.incorporationDate) {
<div class="preview-field">
<span class="preview-label">Incorporation Date</span>
<span class="preview-value">{{ kycData.incorporationDate | dateFormat }}</span>
</div>
}
@if (kycData.gstinNumber) {
<div class="preview-field">
<span class="preview-label">GSTIN Number</span>