feat(clients, alm): add CIN/MCA verification & authorized signatories tab; refactor ALM report print and export
This commit is contained in:
+110
-5
@@ -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 & 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">₹</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">₹</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 · ₹1 crore to ₹10 crore — Medium · 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;
|
||||
|
||||
+179
-24
@@ -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 & 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 & 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 & 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
|
||||
|
||||
+6
-6
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user