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,