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

This commit is contained in:
2026-08-13 15:05:16 +05:30
parent 75df8865dd
commit 5581d4180d
29 changed files with 3002 additions and 2453 deletions
@@ -136,7 +136,14 @@
| translate
}}
</mat-label>
<input matInput required formControlName="fullname" autocomplete="off" />
<input
matInput
required
formControlName="fullname"
autocomplete="off"
(blur)="capitalizeControl('fullname')"
style="text-transform: capitalize"
/>
@if (createClientForm.controls.fullname.hasError('required')) {
<mat-error>Client name is <strong>required</strong></mat-error>
} @else if (createClientForm.controls.fullname.hasError('pattern')) {
@@ -151,7 +158,9 @@
<div class="full-name-row full-width">
<div class="full-name-preview">
<span class="full-name-label">Entity Name:</span>
<span class="full-name-value">{{ createClientForm.get('fullname')?.value }}</span>
<span class="full-name-value" style="text-transform: capitalize">{{
createClientForm.get('fullname')?.value
}}</span>
</div>
<div class="verify-inline">
@if (!sanctionsVerified && !sanctionsBlocked) {
@@ -214,7 +223,14 @@
<mat-form-field appearance="outline" class="name-field first-name">
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>badge</mat-icon></span>
<input matInput required formControlName="firstname" autocomplete="off" />
<input
matInput
required
formControlName="firstname"
autocomplete="off"
(blur)="capitalizeControl('firstname')"
style="text-transform: capitalize"
/>
@if (createClientForm.controls.firstname.hasError('required')) {
<mat-error>First name is <strong>required</strong></mat-error>
} @else if (createClientForm.controls.firstname.hasError('pattern')) {
@@ -228,7 +244,13 @@
<mat-form-field appearance="outline" class="name-field middle-name">
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>person_outline</mat-icon></span>
<input matInput formControlName="middlename" autocomplete="off" />
<input
matInput
formControlName="middlename"
autocomplete="off"
(blur)="capitalizeControl('middlename')"
style="text-transform: capitalize"
/>
@if (
createClientForm.controls.middlename?.hasError('pattern') &&
createClientForm.controls.middlename?.touched
@@ -241,7 +263,14 @@
<mat-form-field appearance="outline" class="name-field last-name">
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span>
<input matInput required formControlName="lastname" autocomplete="off" />
<input
matInput
required
formControlName="lastname"
autocomplete="off"
(blur)="capitalizeControl('lastname')"
style="text-transform: capitalize"
/>
@if (createClientForm.controls.lastname.hasError('required')) {
<mat-error>Last name is <strong>required</strong></mat-error>
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
@@ -694,6 +723,9 @@
}
</mat-form-field>
<!-- Date of Incorporation is captured on the KYC step, next to the entity PAN it is
verified against — see client-kyc-step's `incorporationDate` control. -->
<!-- Authorized Signatories (multiple, registered clients) -->
<div class="form-field full-width signatory-section">
<h4 class="section-title signatory-title">
@@ -1148,6 +1180,79 @@
</div>
}
<!--
Section: Turnover & Risk (Non-Individual only)
An entity has no salary to declare, so the individual Financial & Classification section below
never applied to it — which left a company with no turnover on file at all, and its risk stuck
on the flat entity default. Turnover is the size signal the branch actually has at onboarding,
so it is captured here and the risk band it produces is shown right beside it: the officer sees
the grade move as they type, instead of first meeting it on the KYC tab.
-->
@if (createClientForm.get('legalFormId')?.value === LegalFormId.ENTITY) {
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="chart-line" class="section-icon"></fa-icon>
Turnover &amp; Risk Profile
</h4>
<div class="form-grid">
<mat-form-field appearance="outline" class="form-field">
<mat-label>Monthly Turnover</mat-label>
<span matPrefix class="rupee-prefix">&#8377;</span>
<input matInput type="number" formControlName="monthlyTurnover" min="0" />
<mat-hint>Yearly turnover fills in automatically (× 12)</mat-hint>
@if (
createClientForm.get('monthlyTurnover')?.hasError('pattern') &&
createClientForm.get('monthlyTurnover')?.touched
) {
<mat-error>Enter a valid amount</mat-error>
} @else if (
createClientForm.get('monthlyTurnover')?.hasError('min') && createClientForm.get('monthlyTurnover')?.touched
) {
<mat-error>Amount cannot be negative</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Annual Turnover</mat-label>
<span matPrefix class="rupee-prefix">&#8377;</span>
<input matInput type="number" formControlName="annualTurnover" min="0" />
<mat-hint>Drives the risk grade below</mat-hint>
@if (
createClientForm.get('annualTurnover')?.hasError('pattern') &&
createClientForm.get('annualTurnover')?.touched
) {
<mat-error>Enter a valid amount</mat-error>
} @else if (
createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched
) {
<mat-error>Amount cannot be negative</mat-error>
}
</mat-form-field>
<div
class="full-width entity-risk-card"
[class.entity-risk-card--low]="entityTurnoverRisk.category === 1"
[class.entity-risk-card--medium]="entityTurnoverRisk.category === 2"
[class.entity-risk-card--high]="entityTurnoverRisk.category === 3"
>
<div class="entity-risk-card__head">
<fa-icon icon="shield-alt" class="entity-risk-card__icon"></fa-icon>
<span class="entity-risk-card__title">Auto-calculated Risk Category</span>
<span class="entity-risk-card__badge">{{ riskCategoryLabel(entityTurnoverRisk.category) }}</span>
</div>
<p class="entity-risk-card__reason">{{ entityTurnoverRisk.reason }}</p>
<p class="entity-risk-card__scale">
Up to ₹1 crore — Low &nbsp;·&nbsp; ₹1 crore to ₹10 crore — Medium &nbsp;·&nbsp; Above ₹10 crore — High
</p>
<p class="entity-risk-card__note">
Carried into the KYC tab, where it can still be overridden with a recorded reason.
</p>
</div>
</div>
</div>
}
<!-- Section: Financial & Classification (Individual only) -->
@if (createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
<div class="section-block">
@@ -56,6 +56,103 @@ form {
grid-column: 1 / -1;
}
/* Live risk grade beside the entity's turnover fields. Neutral by default; the accent tracks the
band so a jump from Medium to High is visible without reading the sentence. */
.entity-risk-card {
margin: 4px 0 12px;
padding: 12px 14px;
border-radius: 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-left: 4px solid #94a3b8;
&__head {
display: flex;
align-items: center;
gap: 8px;
}
&__icon {
color: #64748b;
}
&__title {
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
&__badge {
margin-left: auto;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.3px;
text-transform: uppercase;
background: #e2e8f0;
color: #334155;
}
&__reason {
margin: 8px 0 0;
font-size: 13px;
color: #334155;
}
&__scale,
&__note {
margin: 4px 0 0;
font-size: 11px;
color: #64748b;
}
&--low {
background: #f0fdf4;
border-color: #bbf7d0;
border-left-color: #16a34a;
.entity-risk-card__icon {
color: #16a34a;
}
.entity-risk-card__badge {
background: #dcfce7;
color: #15803d;
}
}
&--medium {
background: #fffbeb;
border-color: #fde68a;
border-left-color: #d97706;
.entity-risk-card__icon {
color: #d97706;
}
.entity-risk-card__badge {
background: #fef3c7;
color: #b45309;
}
}
&--high {
background: #fef2f2;
border-color: #fecaca;
border-left-color: #dc2626;
.entity-risk-card__icon {
color: #dc2626;
}
.entity-risk-card__badge {
background: #fee2e2;
color: #b91c1c;
}
}
}
.senior-citizen-auto-note {
display: flex;
align-items: center;
@@ -32,6 +32,7 @@ import { filter, switchMap, takeUntil } from 'rxjs/operators';
import { ClientsService } from 'app/clients/clients.service';
import { Dates } from 'app/core/utils/dates';
import { LegalFormId } from 'app/clients/models/legal-form.enum';
import { toProperNameCase } from 'app/clients/utils/name-case.util';
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
/** Custom Services */
@@ -654,6 +655,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
if (this.createClientForm.get('guardianDetails')) {
this.createClientForm.removeControl('guardianDetails');
}
// Same reasoning as the guardian above: the reused component instance would otherwise carry the
// previous entity's authorized signatories into a brand new client.
this.signatories = [];
this.signatorySearchText = '';
this.signatorySearchResults = [];
// Verification state must not carry over — a previous client's cleared sanctions
// screening or verified OTP must not silently apply to a different new client.
this.mobileVerified = false;
@@ -777,8 +783,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
incorpNumber: data.clientNonPersonDetails.incorpNumber || '',
remarks: data.clientNonPersonDetails.remarks || '',
authorizedSignatory: data.clientNonPersonDetails.authorizedSignatory || '',
authorizedSignatoryClientIds: data.clientNonPersonDetails.authorizedSignatoryClientIds || '',
additionalInfo: data.clientNonPersonDetails.additionalInfo || ''
});
this.restoreSignatories(
data.clientNonPersonDetails.authorizedSignatoryClientIds,
data.clientNonPersonDetails.authorizedSignatory
);
}
}
@@ -971,7 +982,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
/**
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
* and annual income for annual/yearly turnover).
* and annual income for annual/yearly turnover), but only if the turnover fields
* have not been manually modified or cleared.
*/
private linkIncomeToTurnover(): void {
const monthlyIncome = this.createClientForm.get('monthlyIncome');
@@ -979,23 +991,33 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
const annualIncome = this.createClientForm.get('annualIncome');
const annualTurnover = this.createClientForm.get('annualTurnover');
if (monthlyIncome && monthlyTurnover) {
const isEmpty = (val: any) => val === '' || val === null || val === undefined;
if (monthlyIncome && monthlyTurnover && annualTurnover) {
monthlyIncome.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
const isPristine = monthlyTurnover.pristine && annualTurnover.pristine;
const isTurnoverEmpty = isEmpty(monthlyTurnover.value) && isEmpty(annualTurnover.value);
if (isPristine || isTurnoverEmpty) {
const num = parseFloat(value);
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
}
});
}
if (annualIncome && annualTurnover) {
if (annualIncome && annualTurnover && monthlyTurnover) {
annualIncome.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
const isPristine = monthlyTurnover.pristine && annualTurnover.pristine;
const isTurnoverEmpty = isEmpty(monthlyTurnover.value) && isEmpty(annualTurnover.value);
if (isPristine || isTurnoverEmpty) {
const num = parseFloat(value);
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
}
});
}
}
@@ -1122,6 +1144,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
mainBusinessLineId: [''],
incorpNumber: [''],
authorizedSignatory: [''],
// Client ids behind the names above, in the same order — what lets an edit rebuild the
// signatory rows with their client links and PAN/DIN state. No input is bound to it;
// syncSignatoryNames() keeps it in step with the chips.
authorizedSignatoryClientIds: [''],
additionalInfo: [''],
remarks: ['']
})
@@ -1343,6 +1369,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.removeGuardian();
}
capitalizeControl(controlName: string) {
const control = this.createClientForm.get(controlName);
if (control && control.value) {
const originalValue = control.value;
const capitalized = toProperNameCase(originalValue);
if (originalValue !== capitalized) {
control.setValue(capitalized);
}
}
}
/**
* Verify client name against sanctions lists via dialog.
* Passes the client name to the dialog for real API screening.
@@ -1467,6 +1504,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
target.mobileNo = data.mobileNo || null;
target.emailAddress = data.emailAddress || null;
target.officeName = data.officeName || null;
// Filled in only when the caller has not supplied them. A signatory restored from stored
// ids starts as a bare { id } and needs both to render its row; one picked from the search
// dropdown already carries them, and a guardian must keep whatever it was given.
target.displayName = target.displayName || data.displayName;
target.status = target.status || data.status;
target._loading = false;
},
error: () => {
@@ -1480,6 +1522,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
target.panVerified = kyc?.panVerified || false;
target.aadhaarMasked = kyc?.aadhaarNumberMasked || '-';
target.aadhaarVerified = kyc?.aadhaarVerified || false;
// Same reasoning as above: a DIN verified in an earlier session lives on the signatory's own
// KYC record, so re-reading it here is what stops the edit screen asking for it again.
target.dinNumber = target.dinNumber || kyc?.dinNumber || '';
target.dinVerified = target.dinVerified || kyc?.dinVerified || false;
},
error: () => {
target.panNumber = '-';
@@ -1678,9 +1724,49 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
});
}
/**
* Rebuilds the authorized-signatory rows when an existing entity client is opened for edit.
*
* Driven by the stored client ids rather than the stored names: the ids are what make each row a
* real client again, so its CIF, PAN and DIN verification can be re-read instead of the officer
* having to re-add and re-verify signatories that were already recorded. The saved names are used
* only as the label until the lookup returns, and as the fallback for a client that can no longer
* be fetched, so a row is never silently dropped.
*
* Clients created before the ids were stored have names but no ids; those still list, as
* name-only rows, rather than showing nothing at all.
*/
private restoreSignatories(storedIds: string | null | undefined, storedNames: string | null | undefined): void {
const ids = (storedIds || '')
.split(',')
.map((id: string) => id.trim())
.filter(Boolean);
const names = (storedNames || '')
.split(',')
.map((name: string) => name.trim())
.filter(Boolean);
if (!ids.length) {
this.signatories = names.map((displayName: string) => ({ displayName }));
return;
}
this.signatories = ids.map((id: string, index: number) => ({
id: Number(id),
displayName: names[index] || '',
_loading: true
}));
this.signatories.forEach((sig: any) => this.fetchClientFullDetails(sig.id, sig));
}
private syncSignatoryNames(): void {
const names = this.signatories.map((s) => s.displayName).join(', ');
this.createClientForm.get('clientNonPersonDetails')?.patchValue({ authorizedSignatory: names });
// Ids are written in the same order as the names, so the two columns line up on reload.
const clientIds = this.signatories.map((s) => s.id).join(',');
this.createClientForm.get('clientNonPersonDetails')?.patchValue({
authorizedSignatory: names,
authorizedSignatoryClientIds: clientIds
});
}
/** Watch MSME turnover & investment for auto-classification */
@@ -2164,6 +2250,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
delete generalDetails[key];
}
}
// Entity name is normalised here as well as on blur: a pasted/auto-filled value, or one the
// user never blurred out of before submitting, would otherwise reach Fineract exactly as typed.
if (generalDetails.fullname) {
generalDetails.fullname = toProperNameCase(generalDetails.fullname);
}
if (generalDetails.submittedOnDate instanceof Date) {
generalDetails.submittedOnDate = this.dateUtils.formatDate(generalDetails.submittedOnDate, dateFormat);
}
@@ -2233,15 +2325,86 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
];
unsupportedFields.forEach((f) => delete generalDetails[f]);
// Format dateOfRegistration if present
if (generalDetails.clientNonPersonDetails?.dateOfRegistration instanceof Date) {
generalDetails.clientNonPersonDetails.dateOfRegistration = this.dateUtils.formatDate(
generalDetails.clientNonPersonDetails.dateOfRegistration,
dateFormat
);
return generalDetails;
}
/**
* Turnover ladder that decides an entity's risk when no configured risk rule matches it.
*
* A company's size is the one thing about it the branch always knows at onboarding, and the
* bigger the book the more exposure it carries — so the band moves up with annual turnover
* instead of every entity landing on a flat Medium regardless of whether it turns over ten lakh
* or a hundred crore. Thresholds follow the MSME-style split the business already thinks in
* (crores), and are only a fallback: a rule configured in m_client_risk_rule with
* minAnnualTurnover / maxAnnualTurnover still wins, which is where a bank sets its own numbers.
*
* Amounts are in rupees. `upTo: null` is the open-ended top band.
*/
private static readonly ENTITY_TURNOVER_RISK_BANDS: { upTo: number | null; category: number; label: string }[] = [
{ upTo: 10000000, category: 1, label: 'up to ₹1 crore' },
{ upTo: 100000000, category: 2, label: '₹1 crore to ₹10 crore' },
{ upTo: null, category: 3, label: 'above ₹10 crore' }
];
/** "Low Risk" / "Medium Risk" / "High Risk" for a category id. */
riskCategoryLabel(category: number): string {
if (category === 3) {
return 'High Risk';
}
return category === 2 ? 'Medium Risk' : 'Low Risk';
}
/**
* Entity risk from annual turnover, for the fallback path only.
*
* AML is deliberately left CLEAR even in the top band. A configured High-risk rule defaults to
* FLAGGED because someone decided that case warrants it; turnover on its own is a size signal,
* not a suspicion, and auto-flagging every large company would put them all in the AML queue.
*/
private entityRiskFromTurnover(annualTurnover: number): {
category: number;
reason: string;
amlCheckStatus: string;
bannedEntityMatch: string;
willfulDefaulter: string;
ecgcSalFlag: string;
dormantFlag: string;
} {
const base = {
amlCheckStatus: 'CLEAR',
bannedEntityMatch: 'NO',
willfulDefaulter: 'NO',
ecgcSalFlag: 'NO',
dormantFlag: 'ACTIVE'
};
// Nothing captured yet — the entity default stands until a turnover is entered.
if (!annualTurnover || annualTurnover <= 0) {
return {
category: 2,
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
...base
};
}
return generalDetails;
const band =
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS.find((b) => b.upTo == null || annualTurnover <= b.upTo) ??
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS[
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS.length - 1
];
return {
category: band.category,
reason: `Annual turnover ₹${annualTurnover.toLocaleString('en-IN')} — ${band.label} (${this.riskCategoryLabel(
band.category
)})`,
...base
};
}
/** Live risk band for the entity turnover card in the General step. */
get entityTurnoverRisk(): { category: number; reason: string } {
return this.calculatedRisk;
}
get calculatedRisk(): {
@@ -2338,15 +2501,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// Default Fallbacks
if (legalFormId !== LegalFormId.PERSON) {
return {
category: 2,
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
amlCheckStatus: 'CLEAR',
bannedEntityMatch: 'NO',
willfulDefaulter: 'NO',
ecgcSalFlag: 'NO',
dormantFlag: 'ACTIVE'
};
return this.entityRiskFromTurnover(annualTurnover);
}
return {
category: 1,
@@ -588,95 +588,23 @@
<!-- ══ NON-INDIVIDUAL KYC ══ -->
@if (isNonIndividual) {
<!-- Incorporation Date -->
<mat-form-field appearance="outline" class="form-field" (click)="incorpDatePicker.open()">
<mat-label>Incorporation Date</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>calendar_today</mat-icon></span>
<input matInput [max]="today" [matDatepicker]="incorpDatePicker" formControlName="incorporationDate" />
<mat-datepicker-toggle matSuffix [for]="incorpDatePicker"></mat-datepicker-toggle>
<mat-datepicker #incorpDatePicker></mat-datepicker>
<!-- Date of Incorporation — captured before PAN because Enterprise PAN verification
matches the entity on name + date of incorporation as held in the PAN records. -->
<mat-form-field appearance="outline" class="form-field" (click)="incorporationDatePicker.open()">
<mat-label>Date of Incorporation</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>event_available</mat-icon></span>
<input
matInput
[max]="today"
[matDatepicker]="incorporationDatePicker"
formControlName="incorporationDate"
readonly
/>
<mat-datepicker-toggle matSuffix [for]="incorporationDatePicker"></mat-datepicker-toggle>
<mat-datepicker #incorporationDatePicker></mat-datepicker>
<mat-hint>As per Certificate of Incorporation / Registration</mat-hint>
</mat-form-field>
<!-- GSTIN (all non-individual) with Verify -->
<div class="form-field verify-field">
<mat-form-field appearance="outline" class="verify-input">
<mat-label>GSTIN Number</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>receipt_long</mat-icon></span>
<input
matInput
formControlName="gstinNumber"
placeholder="15-character GSTIN"
maxlength="15"
style="text-transform: uppercase"
(input)="
kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
}
@if (gstinVerifyStatus === 'VERIFIED') {
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
}
@if (gstinVerifyStatus === 'FAILED') {
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
}
</mat-form-field>
<div class="verify-action">
@if (gstinVerifyStatus === 'NONE' || gstinVerifyStatus === 'FAILED') {
<button
mat-raised-button
color="primary"
type="button"
class="verify-btn-inline"
[disabled]="
readOnly ||
!kycForm.get('gstinNumber')?.value ||
kycForm.get('gstinNumber')?.value.length < 15 ||
gstinVerifying ||
!clientId
"
(click)="verifyGstin()"
>
@if (gstinVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
<fa-icon icon="shield-alt"></fa-icon> Verify GSTIN
}
</button>
}
@if (gstinVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
</div>
</div>
@if (gstinVerifyStatus === 'VERIFIED') {
<div class="pan-result-card full-width">
<div class="pan-result-header">
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
<span>GSTIN Verified Successfully</span>
</div>
<div class="pan-result-body">
@if (gstinVerifyName) {
<div class="pan-result-row">
<span class="pan-result-label">Trade Name / Legal Name</span>
<span class="pan-result-value name">{{ gstinVerifyName }}</span>
</div>
}
@if (gstinVerifyState) {
<div class="pan-result-row">
<span class="pan-result-label">State</span>
<span class="pan-result-value">{{ gstinVerifyState }}</span>
</div>
}
<div class="pan-result-row">
<span class="pan-result-label">Verified On</span>
<span class="pan-result-value date">{{ gstinVerifiedOn }}</span>
</div>
</div>
</div>
}
<!-- PAN — Proprietorship uses Individual PAN, others use Enterprise PAN -->
@if (constitutionMeta?.value === 'PROPRIETORSHIP') {
<!-- Proprietorship: Individual PAN (proprietor's personal PAN) -->
@@ -826,8 +754,88 @@
}
}
<!-- CIN (Companies & LLP only) -->
@if (constitutionMeta?.showCin) {
<!-- GSTIN (all non-individual) with Verify -->
<div class="form-field verify-field">
<mat-form-field appearance="outline" class="verify-input">
<mat-label>GSTIN Number</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>receipt_long</mat-icon></span>
<input
matInput
formControlName="gstinNumber"
placeholder="15-character GSTIN"
maxlength="15"
style="text-transform: uppercase"
(input)="
kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
}
@if (gstinVerifyStatus === 'VERIFIED') {
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
}
@if (gstinVerifyStatus === 'FAILED') {
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
}
</mat-form-field>
<div class="verify-action">
@if (gstinVerifyStatus === 'NONE' || gstinVerifyStatus === 'FAILED') {
<button
mat-raised-button
color="primary"
type="button"
class="verify-btn-inline"
[disabled]="
readOnly ||
!kycForm.get('gstinNumber')?.value ||
kycForm.get('gstinNumber')?.value.length < 15 ||
gstinVerifying ||
!clientId
"
(click)="verifyGstin()"
>
@if (gstinVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
<fa-icon icon="shield-alt"></fa-icon> Verify GSTIN
}
</button>
}
@if (gstinVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
</div>
</div>
@if (gstinVerifyStatus === 'VERIFIED') {
<div class="pan-result-card full-width">
<div class="pan-result-header">
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
<span>GSTIN Verified Successfully</span>
</div>
<div class="pan-result-body">
@if (gstinVerifyName) {
<div class="pan-result-row">
<span class="pan-result-label">Trade Name / Legal Name</span>
<span class="pan-result-value name">{{ gstinVerifyName }}</span>
</div>
}
@if (gstinVerifyState) {
<div class="pan-result-row">
<span class="pan-result-label">State</span>
<span class="pan-result-value">{{ gstinVerifyState }}</span>
</div>
}
<div class="pan-result-row">
<span class="pan-result-label">Verified On</span>
<span class="pan-result-value date">{{ gstinVerifiedOn }}</span>
</div>
</div>
</div>
}
<!-- CIN -->
@if (isNonIndividual) {
<div class="form-field verify-field">
<mat-form-field appearance="outline" class="verify-input">
<mat-label>CIN (Corporate Identity Number)</mat-label>
@@ -850,7 +858,7 @@
}
</mat-form-field>
<div class="verify-action">
@if (cinVerifyStatus === 'NONE' || cinVerifyStatus === 'FAILED') {
@if (cinVerifyStatus !== 'VERIFIED') {
<button
mat-raised-button
color="primary"
@@ -862,16 +870,31 @@
@if (cinVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
<fa-icon icon="shield-alt"></fa-icon> Verify CIN
<fa-icon icon="shield-alt"></fa-icon>
{{ cinVerifyStatus === 'UNCONFIRMED' ? 'Retry' : 'Verify CIN' }}
}
</button>
}
@if (cinVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
@if (cinVerifyStatus === 'UNCONFIRMED') {
<span class="verify-chip unconfirmed"
><fa-icon icon="info-circle"></fa-icon> Saved — not confirmed</span
>
}
</div>
</div>
<!-- Accepted, but MCA returned no match. Stated plainly rather than as a failure: the
number is stored and the officer has nothing to correct. -->
@if (cinUnconfirmedMessage) {
<div class="cin-unconfirmed-note full-width">
<fa-icon icon="info-circle" class="cin-unconfirmed-note__icon"></fa-icon>
<span>{{ cinUnconfirmedMessage }}</span>
</div>
}
<!-- CIN verification progress -->
@if (cinVerifying) {
<div class="cin-progress full-width">
@@ -1429,15 +1452,19 @@
</div>
}
<!--
Overriding the risk category is the one thing that makes remarks mandatory here. A verified
record still gets the box — the officer needs somewhere to record a re-edit reason, and the
server asks for one if the save actually replaces data already on the record — but it is
offered, not demanded, so a record that auto-verified mid-wizard does not open in error.
-->
@if (kycForm.get('allowRiskOverride')?.value || isKycVerified) {
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>
{{
isKycVerified
? 'Reason for Re-edit'
: kycForm.get('allowRiskOverride')?.value
? 'Remarks (Required for Risk Override)'
: 'Remarks'
kycForm.get('allowRiskOverride')?.value
? 'Remarks (Required for Risk Override)'
: 'Reason for Re-edit (optional)'
}}
</mat-label>
<textarea
@@ -1445,18 +1472,13 @@
formControlName="remarks"
rows="2"
[placeholder]="
isKycVerified
? 'Mandatory — explain why KYC is being re-edited'
: kycForm.get('allowRiskOverride')?.value
? 'Mandatory — explain why you are overriding the auto-calculated risk category'
: ''
kycForm.get('allowRiskOverride')?.value
? 'Mandatory — explain why you are overriding the auto-calculated risk category'
: 'Optional — needed only if you change details already recorded on this verified KYC'
"
></textarea>
@if ((isKycVerified || kycForm.get('allowRiskOverride')?.value) && !kycForm.get('remarks')?.value?.trim()) {
<mat-error>
Remarks are mandatory when
{{ isKycVerified ? 'updating a verified KYC record' : 'overriding the risk category' }}
</mat-error>
@if (kycForm.get('allowRiskOverride')?.value && !kycForm.get('remarks')?.value?.trim()) {
<mat-error> Remarks are mandatory when overriding the risk category </mat-error>
}
</mat-form-field>
}
@@ -1672,77 +1694,92 @@
</div>
<!-- ═══ SECTION: Form 97/98 ═══ -->
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
Form 97 / 98
@if (!isNonIndividual) {
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
Form 97 / 98
@if (!kycForm.get('hasPan')?.value) {
<span class="kyc-badge required-badge">Required — No PAN</span>
}
</h4>
@if (!kycForm.get('hasPan')?.value) {
<span class="kyc-badge required-badge">Required — No PAN</span>
}
</h4>
@if (!kycForm.get('hasPan')?.value) {
<div class="kyc-info-hint warning-hint">
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
<span>Client does not have PAN. Form 97/98 is mandatory.</span>
</div>
<div class="form-grid">
<mat-form-field appearance="outline" class="form-field">
<mat-label>Form Type</mat-label>
<mat-select formControlName="form60Type">
<mat-option value="FORM_60">Form 97</mat-option>
<mat-option value="FORM_61">Form 98</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
<mat-label>Declaration Date</mat-label>
<input matInput [matDatepicker]="form60DatePicker" formControlName="form60Date" />
<mat-datepicker-toggle matSuffix [for]="form60DatePicker"></mat-datepicker-toggle>
<mat-datepicker #form60DatePicker></mat-datepicker>
</mat-form-field>
</div>
} @else {
<p class="form60-not-needed">Client has PAN — Form 97/98 not required.</p>
}
</div>
<!-- ═══ SECTION: Form 121 ═══ -->
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="receipt" class="section-icon"></fa-icon>
Form 121
</h4>
<div class="form-grid">
<div class="form-field checkbox-field">
<mat-checkbox formControlName="form15Applicable">Form 121 Applicable</mat-checkbox>
</div>
@if (kycForm.get('form15Applicable')?.value) {
<mat-form-field appearance="outline" class="form-field">
<mat-label>Form Type</mat-label>
<mat-select formControlName="form15Type">
<mat-option value="15G">Form 121 (Below 60 years)</mat-option>
<mat-option value="15H">Form 121 (Senior Citizen 60+)</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Financial Year</mat-label>
<input matInput formControlName="form15FY" placeholder="e.g. 2025-26" />
</mat-form-field>
<div class="kyc-info-hint warning-hint">
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
<span>Client does not have PAN. Form 97/98 is mandatory.</span>
</div>
<div class="form-grid">
<mat-form-field appearance="outline" class="form-field">
<mat-label>Form Type</mat-label>
<mat-select formControlName="form60Type">
<mat-option value="FORM_60">Form 97</mat-option>
<mat-option value="FORM_61">Form 98</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
<mat-label>Declaration Date</mat-label>
<input matInput [matDatepicker]="form60DatePicker" formControlName="form60Date" />
<mat-datepicker-toggle matSuffix [for]="form60DatePicker"></mat-datepicker-toggle>
<mat-datepicker #form60DatePicker></mat-datepicker>
</mat-form-field>
</div>
} @else {
<p class="form60-not-needed">Client has PAN — Form 97/98 not required.</p>
}
</div>
</div>
}
<!-- ═══ SECTION: Photograph & Signature ═══ -->
<!-- ═══ SECTION: Form 121 ═══ -->
@if (!isNonIndividual) {
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="receipt" class="section-icon"></fa-icon>
Form 121
</h4>
<div class="form-grid">
<div class="form-field checkbox-field">
<mat-checkbox formControlName="form15Applicable">Form 121 Applicable</mat-checkbox>
</div>
@if (kycForm.get('form15Applicable')?.value) {
<mat-form-field appearance="outline" class="form-field">
<mat-label>Form Type</mat-label>
<mat-select formControlName="form15Type">
<mat-option value="15G">Form 121 (Below 60 years)</mat-option>
<mat-option value="15H">Form 121 (Senior Citizen 60+)</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Financial Year</mat-label>
<input matInput formControlName="form15FY" placeholder="e.g. 2025-26" />
</mat-form-field>
}
</div>
</div>
}
<!-- ═══ SECTION: Photograph & Signature ═══
An entity gets the photograph box too — a shopfront, signboard or logo is what staff
recognise the client by in the ledger, and the client view has always had a photo slot in
its header with no way to fill it from the stepper. The specimen signature stays individual-
only: an entity signs through its authorised signatories, whose signatures are captured with
their own KYC rather than as one image for the company. Optional here, so a company can
still be onboarded before anyone has a usable photograph of the premises. -->
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="camera" class="section-icon"></fa-icon>
Photograph &amp; Signature
<span class="section-required-tag">Required</span>
{{ isNonIndividual ? 'Entity Photograph' : 'Photograph & Signature' }}
@if (isNonIndividual) {
<span class="section-optional-tag">Optional</span>
} @else {
<span class="section-required-tag">Required</span>
}
</h4>
<div class="photo-sig-grid">
<div class="photo-sig-grid" [class.photo-sig-grid--single]="isNonIndividual">
<div
class="upload-box"
[class.upload-box--has-preview]="photographPreview"
[class.upload-box--missing]="!photographPreview && photoSigTouched"
[class.upload-box--missing]="!photographPreview && photoSigTouched && !isNonIndividual"
>
@if (photographPreview) {
<div class="upload-preview">
@@ -1758,12 +1795,16 @@
</div>
</div>
} @else {
<div class="upload-box__icon"><fa-icon icon="user"></fa-icon></div>
<div class="upload-box__icon"><fa-icon [icon]="isNonIndividual ? 'building' : 'user'"></fa-icon></div>
<span class="upload-box__label">
Client Photograph
<span class="upload-box__req">*</span>
{{ isNonIndividual ? 'Entity Photograph / Logo' : 'Client Photograph' }}
@if (!isNonIndividual) {
<span class="upload-box__req">*</span>
}
</span>
<span class="upload-box__hint">
{{ isNonIndividual ? 'Office premises, signboard or logo — JPG / PNG, max 2MB' : 'JPG / PNG, max 2MB' }}
</span>
<span class="upload-box__hint">JPG / PNG, max 2MB</span>
<div class="upload-box__btn-row">
<label class="upload-box__btn">
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
@@ -1773,50 +1814,52 @@
<fa-icon icon="video"></fa-icon> Use Webcam
</button>
</div>
@if (photoSigTouched) {
@if (photoSigTouched && !isNonIndividual) {
<span class="upload-box__error">Photograph is required</span>
}
}
</div>
<div
class="upload-box"
[class.upload-box--has-preview]="signaturePreview"
[class.upload-box--missing]="!signaturePreview && photoSigTouched"
>
@if (signaturePreview) {
<div class="upload-preview">
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
<div class="upload-preview__actions">
<label class="upload-preview__btn recrop">
@if (!isNonIndividual) {
<div
class="upload-box"
[class.upload-box--has-preview]="signaturePreview"
[class.upload-box--missing]="!signaturePreview && photoSigTouched"
>
@if (signaturePreview) {
<div class="upload-preview">
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
<div class="upload-preview__actions">
<label class="upload-preview__btn recrop">
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
<fa-icon icon="crop"></fa-icon> Re-crop
</label>
<button type="button" class="upload-preview__btn remove" (click)="removeSignature()">
<fa-icon icon="trash"></fa-icon> Remove
</button>
</div>
</div>
} @else {
<div class="upload-box__icon"><fa-icon icon="pen"></fa-icon></div>
<span class="upload-box__label">
Specimen Signature
<span class="upload-box__req">*</span>
</span>
<span class="upload-box__hint">Scanned image, max 1MB</span>
<div class="upload-box__btn-row">
<label class="upload-box__btn">
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
<fa-icon icon="crop"></fa-icon> Re-crop
<fa-icon icon="crop"></fa-icon> Choose &amp; Crop
</label>
<button type="button" class="upload-preview__btn remove" (click)="removeSignature()">
<fa-icon icon="trash"></fa-icon> Remove
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="drawSignature()">
<fa-icon icon="pen"></fa-icon> Draw Signature
</button>
</div>
</div>
} @else {
<div class="upload-box__icon"><fa-icon icon="pen"></fa-icon></div>
<span class="upload-box__label">
Specimen Signature
<span class="upload-box__req">*</span>
</span>
<span class="upload-box__hint">Scanned image, max 1MB</span>
<div class="upload-box__btn-row">
<label class="upload-box__btn">
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
<fa-icon icon="crop"></fa-icon> Choose &amp; Crop
</label>
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="drawSignature()">
<fa-icon icon="pen"></fa-icon> Draw Signature
</button>
</div>
@if (photoSigTouched) {
<span class="upload-box__error">Signature is required</span>
@if (photoSigTouched) {
<span class="upload-box__error">Signature is required</span>
}
}
}
</div>
</div>
}
</div>
</div>
@@ -36,6 +36,20 @@
letter-spacing: 0.4px;
text-transform: uppercase;
}
/* Same chip, muted — says "you may" where the red one says "you must". */
.section-optional-tag {
margin-left: 6px;
padding: 2px 9px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
}
}
.kyc-badge {
@@ -312,6 +326,34 @@
color: #b45309;
border: 1px solid #fcd34d;
}
// Recorded but unconfirmed by the registry. Neutral blue rather than green or red: nothing failed
// and nothing passed, so it must not read as either.
&.unconfirmed {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
}
// ── CIN saved but not confirmed by MCA ─────────────────────────────────────
.cin-unconfirmed-note {
display: flex;
align-items: flex-start;
gap: 8px;
margin: -4px 0 12px;
padding: 10px 12px;
border-radius: 8px;
background: #eff6ff;
border: 1px solid #bfdbfe;
color: #1e40af;
font-size: 12.5px;
line-height: 1.45;
}
.cin-unconfirmed-note__icon {
margin-top: 1px;
flex-shrink: 0;
}
// ── PAN declaration summary ────────────────────────────────────────────────
@@ -1225,6 +1267,12 @@
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
/* Entity clients get the photograph box only, so it keeps a card's width instead of
stretching across the whole step where the signature box used to sit beside it. */
&--single {
grid-template-columns: minmax(0, 420px);
}
}
.upload-box {
@@ -29,6 +29,7 @@ import { ClientsService } from '../../clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
import { ENTITY_KYC_DOCUMENT_TYPES, KycDocumentType, withEntityKycDocuments } from '../../shared/entity-kyc-documents';
import { deriveKycExceptionFields } from '../../kyc-exception.util';
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
@@ -110,6 +111,27 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
} | null = null;
today = new Date();
/**
* ClientKycType on the backend: 1 = INDIVIDUAL, 2 = NON_INDIVIDUAL. Every payload must carry the
* value matching the client's legal form — the backend rejects the entity-only fields
* (enterprisePan / gstinNumber / cinNumber / incorporationDate) on a KYC record typed INDIVIDUAL.
*/
get kycTypeValue(): number {
return this.isNonIndividual ? 2 : 1;
}
/**
* Risk category as it will actually be saved.
*
* Read via getRawValue() because the riskCategory control is disabled whenever the override
* checkbox is off — which is the normal case — and `kycForm.value` omits disabled controls.
* Reading `.value` there yields undefined and quietly falls back to 1 (Low), so a verification
* would stamp Low onto the record and the next full save would change it to the auto-calculated
* category, showing up as an edit of a verified record that the officer never made.
*/
private get currentRiskCategory(): number {
return this.kycForm.getRawValue().riskCategory || 1;
}
kycId: string = '';
/** Whether existing KYC data has been loaded (edit mode) */
kycLoaded = false;
@@ -121,12 +143,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
}
/** KYC Document Types — populated from API template, falls back to defaults */
kycDocumentTypes: { name: string; code: string; mandatory: boolean }[] = [];
kycDocumentTypes: KycDocumentType[] = [];
/** Chip-group state for KYC document picker (mirrors identity proof in identifier dialog) */
kycDocProofs: ProofType[] = [];
kycDocGroups: { label: string; items: ProofType[] }[] = [];
selectedKycDoc: ProofType | null = null;
/**
* Constitution whose document list is currently loaded. Distinguishes the first arrival of a
* constitution from the officer switching to a different one — see updateDocTypesForConstitution.
*/
private appliedConstitutionValue: string | null = null;
/** Front/back files for the chosen KYC document */
kycDocFiles: { front: File | null; back: File | null } = { front: null, back: null };
/**
@@ -185,6 +212,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Photo + signature are mandatory before leaving the KYC tab */
get photoSigComplete(): boolean {
if (this.isNonIndividual) {
return true;
}
return this.hasPhoto && this.hasSignature;
}
@@ -225,7 +255,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
const needsCin = !!this.kycForm?.get('cinNumber')?.value;
const panOk = this.entPanVerifyStatus === 'VERIFIED';
const gstinOk = !needsGstin || this.gstinVerifyStatus === 'VERIFIED';
const cinOk = !needsCin || this.cinVerifyStatus === 'VERIFIED';
// A CIN the provider cannot confirm still counts as done. MCA lookups return no match for
// CINs that are perfectly valid, and PAN and GSTIN already establish the entity's identity —
// holding the whole KYC open on a check that cannot pass would strand the client instead.
const cinOk = !needsCin || this.cinVerifyStatus === 'VERIFIED' || this.cinVerifyStatus === 'UNCONFIRMED';
return panOk && gstinOk && cinOk;
} else {
const needsPan = !this.isMinor && this.kycForm?.get('hasPan')?.value;
@@ -250,18 +283,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
{ name: 'Birth Certificate', code: 'BIRTH_CERTIFICATE' },
{ name: 'School ID Card', code: 'SCHOOL_ID_CARD' }
];
/** Generic RBI-prescribed KYC documents for legal entities (Master Direction on KYC,
* Annexure I) — shown for non-individual clients before a specific constitution type is
* picked. Individual OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/NPR) don't apply to entities. */
private nonIndividualDefaultDocTypes = [
{ name: 'Certificate of Incorporation / Registration Certificate', code: 'CERT_INCORPORATION' },
{ name: 'PAN Card of Entity', code: 'ENTITY_PAN' },
{ name: 'Memorandum & Articles of Association / Partnership Deed / Trust Deed', code: 'CONSTITUTIVE_DOC' },
{ name: 'Board Resolution / Authorization Letter', code: 'BOARD_RESOLUTION' },
{ name: 'KYC of Authorized Signatory / Power of Attorney Holder', code: 'AUTH_SIGNATORY_KYC' },
{ name: 'GST Registration Certificate', code: 'GST_CERTIFICATE' }
];
/** Constitution-based KYC document types for non-individual clients */
/** Constitution-based KYC document types for non-individual clients. Shown in addition to
* the RBI entity documents (ENTITY_KYC_DOCUMENT_TYPES), never instead of them — see
* withEntityKycDocuments. */
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
HUF: [
{ name: 'PAN Card of HUF', mandatory: true },
@@ -513,7 +537,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
gstinVerifyState = '';
/** CIN verification (dummy) */
cinVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
/**
* UNCONFIRMED means the CIN is saved and correctly formatted, but MCA (via LincPay) would not
* confirm it. Kept distinct from VERIFIED, which is only ever set by a positive answer from the
* provider, and from FAILED, which is reserved for the provider being unreachable.
*/
cinVerifyStatus: 'NONE' | 'VERIFIED' | 'UNCONFIRMED' | 'FAILED' = 'NONE';
/** Explanation shown beside the CIN when the provider could not confirm it. */
cinUnconfirmedMessage = '';
cinVerifying = false;
cinVerifiedOn = '';
cinCompanyName = '';
@@ -648,9 +679,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Individual-only OVDs vs. entity-only RBI docs — never both, and minor filtering only
* ever applies to individuals. */
private getDefaultDocTypes(): { name: string; code: string }[] {
return this.isNonIndividual
? this.nonIndividualDefaultDocTypes
: this.filterDocTypesForMinor(this.defaultDocumentTypes);
return this.isNonIndividual ? ENTITY_KYC_DOCUMENT_TYPES : this.filterDocTypesForMinor(this.defaultDocumentTypes);
}
ngOnInit(): void {
@@ -673,9 +702,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
if (this.calculatedRisk) {
riskControl?.setValue(this.calculatedRisk.category);
}
if (!this.isKycVerified) {
this.kycForm.get('remarks')?.clearValidators();
}
// Overriding the risk category is the only thing this form demands a justification for.
// A verified record does not, by itself, need one — see loadExistingKycIfNeeded.
this.kycForm.get('remarks')?.clearValidators();
}
this.kycForm.get('remarks')?.updateValueAndValidity();
});
@@ -797,6 +826,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.gstinVerifiedOn = '';
this.gstinVerifyState = '';
this.cinVerifyStatus = 'NONE';
this.cinUnconfirmedMessage = '';
this.cinVerifying = false;
this.cinVerifiedOn = '';
this.cinCompanyName = '';
@@ -898,14 +928,26 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
private updateDocTypesForConstitution(): void {
const constitutionValue = this.constitutionMeta?.value;
if (constitutionValue && this.constitutionDocTypes[constitutionValue]) {
this.kycDocumentTypes = this.constitutionDocTypes[constitutionValue].map((doc) => ({
name: doc.name,
code: doc.name,
mandatory: doc.mandatory
}));
// Reset selected document type since options changed
this.kycForm.get('kycDocumentName')?.setValue('');
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
// The RBI entity documents stay at the head of the list — picking a constitution adds that
// constitution's own requirements, it does not swap out the set the officer was just shown.
this.kycDocumentTypes = withEntityKycDocuments(
this.constitutionDocTypes[constitutionValue],
this.constitutionMeta?.label || null
);
// Clearing the chosen document type only makes sense when the officer switches constitution,
// which genuinely invalidates it. It must NOT happen the first time a constitution arrives:
// on edit that happens after the saved KYC has been restored, so wiping the value here would
// discard the restored document type — and with it the upload slots that display the file
// already held against the client, since they render only once a type is selected.
const constitutionChanged =
this.appliedConstitutionValue !== null && this.appliedConstitutionValue !== constitutionValue;
this.appliedConstitutionValue = constitutionValue;
if (constitutionChanged) {
this.kycForm.get('kycDocumentName')?.setValue('');
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
}
// Re-resolves the selected chip against the new list from whatever name is on the form.
this.rebuildKycDocPicker();
}
}
@@ -913,18 +955,51 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Rebuild chip-group picker model from kycDocumentTypes */
private rebuildKycDocPicker(): void {
this.kycDocProofs = (this.kycDocumentTypes || []).map((d, i) =>
normalizeProofType({ id: d.code || d.name || i, name: d.name, code: d.code, description: d.code || d.name })
normalizeProofType({
id: d.code || d.name || i,
name: d.name,
code: d.code,
description: d.code || d.name,
// Constitution-specific documents carry no code PROOF_META knows, so their group label
// has to be handed over explicitly or they all collect under "Other".
category: d.category
})
);
this.kycDocGroups = groupProofsByCategory(this.kycDocProofs);
// Sync selected proof with current form value (e.g. on edit-mode prefill)
const currentName = this.kycForm?.get('kycDocumentName')?.value;
if (currentName) {
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const targetName = cleanStr(currentName);
this.selectedKycDoc = this.kycDocProofs.find((p) => cleanStr(p.name) === targetName) || null;
} else {
this.selectedKycDoc = null;
this.selectedKycDoc = currentName ? this.findKycDocProofByName(currentName) : null;
}
/**
* Resolves a stored document-type name back to a chip in the picker.
*
* A tolerant match is required because the two names are not written by the same thing: an
* identifier row carries the CodeValue label the backend holds ("Certificate of Incorporation")
* while the picker shows the fuller, constitution-specific label ("Certificate of Incorporation /
* Registration Certificate"), so one is usually contained in the other rather than equal to it.
* An exact comparison leaves the chip unselected on edit, which in turn hides the file-upload
* slots and therefore the document the officer already uploaded.
*/
private findKycDocProofByName(name: string | null | undefined): ProofType | null {
const clean = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const target = clean(name || '');
if (!target) {
return null;
}
const proofs = this.kycDocProofs || [];
const exact = proofs.find((p) => clean(p.name) === target);
if (exact) {
return exact;
}
// Closest containment wins, so a short label cannot claim a chip that a nearer one also matches.
const contained = proofs
.filter((p) => {
const candidate = clean(p.name);
return candidate.includes(target) || target.includes(candidate);
})
.sort((a, b) => Math.abs(clean(a.name).length - target.length) - Math.abs(clean(b.name).length - target.length));
return contained[0] || null;
}
selectKycDoc(proof: ProofType): void {
@@ -1102,19 +1177,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
});
// Set remarks as required if KYC is verified
if (this.isKycVerified) {
this.kycForm.get('remarks')?.setValidators(Validators.required);
this.kycForm.get('remarks')?.updateValueAndValidity();
this.kycForm.get('remarks')?.markAsTouched(); // Force red underline so user knows it's blocking
}
// Remarks are NOT forced just because the record is verified. The server asks for them
// only when a save actually overwrites data the verified record already held
// (ClientKycWritePlatformServiceJpaRepositoryImpl.updateKyc), and it says so in the error
// it returns. Demanding them up front made every non-individual client open with a red
// "Reason for Re-edit" the moment PAN and GSTIN auto-verified — before the officer had
// changed anything, and while they still had the KYC document fields left to fill in.
this.persistedIsException = data.isException === true;
this.persistedExceptionReason = data.exceptionReason || null;
// Sync chip-picker selection with the prefilled document name
if (data.kycDocumentName) {
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const targetName = cleanStr(data.kycDocumentName);
this.selectedKycDoc = this.kycDocProofs.find((p) => cleanStr(p.name) === targetName) || null;
this.selectedKycDoc = this.findKycDocProofByName(data.kycDocumentName);
}
// Set verification statuses from existing data
if (data.panVerified) {
@@ -1175,6 +1248,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
}
if (data.cinVerified) {
this.cinVerifyStatus = 'VERIFIED';
// The verified card shows this; without it a reopened client renders the panel blank.
this.cinCompanyName = data.cinCompanyName || 'Verified via MCA';
const rawCinDate = data.cinVerifiedOn;
if (Array.isArray(rawCinDate) && rawCinDate.length >= 3) {
this.cinVerifiedOn = `${rawCinDate[2]?.toString().padStart(2, '0')}/${rawCinDate[1]?.toString().padStart(2, '0')}/${rawCinDate[0]}`;
@@ -1243,11 +1318,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// Backfill document type + number on the form in case the KYC record didn't have
// them (older clients were created via the identifier flow only).
if (!this.kycForm.get('kycDocumentName')?.value && identifier?.documentType?.name) {
const proof = this.findKycDocProofByName(identifier.documentType.name);
this.selectedKycDoc = proof;
// Store the picker's own label rather than the identifier's when one matched, so the
// selected chip and the form value agree and a later save round-trips the same name.
this.kycForm.patchValue({
kycDocumentName: identifier.documentType.name,
kycDocumentName: proof?.name || identifier.documentType.name,
kycDocumentNumber: identifier.documentKey || ''
});
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === identifier.documentType.name) || null;
}
this.clientsService.getClientIdentificationDocuments(this.existingKycIdentifierId).subscribe({
@@ -1453,13 +1531,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycTemplate = template;
this.kycTemplateLoaded = true;
if (template?.requiredDocuments?.length > 0) {
this.kycDocumentTypes = this.filterDocTypesForMinor(
template.requiredDocuments.map((doc: any) => ({
name: doc.name,
code: doc.code || doc.name,
mandatory: doc.mandatory ?? false
}))
);
const templateDocs = template.requiredDocuments.map((doc: any) => ({
name: doc.name,
code: doc.code || doc.name,
mandatory: doc.mandatory ?? false
}));
// The backend template names the same entity documents differently ("Certificate of
// Incorporation" / "COI", "PAN Card of Company" / "PAN"), so for an entity it is merged
// onto the RBI list rather than replacing it — otherwise reopening a non-individual
// client for edit shows a different set of chips than the one used to create it.
this.kycDocumentTypes = this.isNonIndividual
? withEntityKycDocuments(templateDocs, template?.clientTypeName || this.constitutionMeta?.label || null)
: this.filterDocTypesForMinor(templateDocs);
// Update kycDocumentName validator based on mandatory docs
const hasMandatory = this.kycDocumentTypes.some((d) => d.mandatory);
if (hasMandatory) {
@@ -1472,11 +1555,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
error: () => {
// Fallback to defaults if template API unavailable
this.kycTemplateLoaded = true;
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}));
// An entity keeps whatever constitution-specific documents it already has alongside the
// RBI list; a failed template call must not quietly shorten the picker.
const constitutionDocs = this.isNonIndividual
? this.constitutionDocTypes[this.constitutionMeta?.value] || []
: [];
this.kycDocumentTypes = this.isNonIndividual
? withEntityKycDocuments(constitutionDocs, this.constitutionMeta?.label || null)
: this.getDefaultDocTypes().map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}));
this.rebuildKycDocPicker();
}
});
@@ -1485,17 +1575,25 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Save or update KYC, then execute callback */
private saveKycAndDo(payload: any, callback: () => void, onError: (msg: string) => void) {
if (this.kycId) {
const { kycType, ...updatePayload } = payload;
// riskCategory is dropped alongside kycType on an update. The verify payloads only carry it to
// satisfy the initiate validator, which requires it when the record is first created; once the
// row exists, re-asserting it on every PAN/GSTIN/CIN check means a verification silently
// rewrites the risk category. On a record that has auto-verified that reads as an edit of
// verified data and is refused, blocking the verification itself. The officer's risk choice is
// persisted by the KYC step's own save, which does not come through here.
const { kycType, riskCategory, ...updatePayload } = payload;
// If KYC is VERIFIED, remarks are mandatory — use re-edit command
// If KYC is VERIFIED, changing data already on the record needs remarks — use re-edit command.
// Whether this particular save actually replaces stored data is something only the server can
// tell (it holds the current values), so it decides: a non-individual record auto-verifies as
// soon as PAN and GSTIN pass, and the officer must still be able to fill in the KYC document
// fields that are empty at that point. Remarks are forwarded when present and the server
// rejects the save if they were required.
if (this.isKycVerified) {
const remarks = this.kycForm.get('remarks')?.value?.trim();
if (!remarks) {
onError('Remarks are mandatory when updating a verified KYC record.');
this.kycForm.get('remarks')?.markAsTouched();
return;
if (remarks) {
updatePayload.remarks = remarks;
}
updatePayload.remarks = remarks;
this.clientsService.reEditClientKyc(this.clientId, this.kycId, updatePayload).subscribe({
next: () => {
// Status transitions to IN_PROGRESS, PAN verification is invalidated
@@ -1571,12 +1669,30 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
const panPayload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
kycType: this.kycTypeValue,
riskCategory: this.currentRiskCategory,
panNumber: pan,
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000'
};
// A proprietorship reaches this method (it verifies the proprietor's individual PAN) but is
// still legalForm=ENTITY to the backend, which matches an entity on name + date of
// incorporation. Persist that date with this save so verifyPAN() can read it off the record.
// Never sent for individuals — the backend rejects entity-only fields on an individual KYC.
if (this.isNonIndividual) {
const incorpDate = this.kycForm.value.incorporationDate;
const dateFormat = this.settingsService.dateFormat;
panPayload.dateFormat = dateFormat;
panPayload.incorporationDate =
incorpDate instanceof Date ? this.dateUtils.formatDate(incorpDate, dateFormat) : incorpDate || null;
if (!panPayload.incorporationDate) {
this.panVerifying = false;
this.panVerifyError = 'Enter the Date of Incorporation before verifying the PAN.';
this.kycForm.get('incorporationDate')?.markAsTouched();
return;
}
}
this.saveKycAndDo(
panPayload,
() => {
@@ -1834,16 +1950,27 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
const dateFormat = this.settingsService.dateFormat;
const incorpDate = this.kycForm.value.incorporationDate;
// Same payload structure as individual — backend uses panNumber + client name/DOB for verification
// Same payload structure as individual — backend uses panNumber + client name/DOB for verification.
// For an entity the provider's "DOB" is the date of incorporation, so it must be persisted on the
// KYC record by this same save — verifyPAN() below reads it back off the record, not off the form.
const payload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
kycType: this.kycTypeValue,
riskCategory: this.currentRiskCategory,
panNumber: pan, // Company PAN → panNumber
enterprisePan: pan,
incorporationDate: incorpDate instanceof Date ? this.dateUtils.formatDate(incorpDate, dateFormat) : incorpDate,
dateFormat,
aadhaarNumberMasked: 'XXXXXXXX0000' // Placeholder — not applicable for entities
};
if (!payload.incorporationDate) {
this.entPanVerifying = false;
this.showPanErrorAlert('Enter the Date of Incorporation before verifying the Enterprise PAN.');
this.kycForm.get('incorporationDate')?.markAsTouched();
return;
}
this.saveKycAndDo(
payload,
() => {
@@ -1862,6 +1989,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
rawDate ||
`${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
}
// Seeding status — mirrors verifyPan(), so the value the provider returned is held
// here too and the next save round-trips it instead of leaving this screen blank.
if (res.changes?.panAadhaarSeedingStatus) {
this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus;
}
} else {
this.entPanVerifyStatus = 'FAILED';
this.showPanErrorAlert(
@@ -1907,8 +2039,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
const payload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
kycType: this.kycTypeValue,
riskCategory: this.currentRiskCategory,
gstinNumber: gstin,
dateFormat,
panNumber: this.kycForm.value.enterprisePan || this.kycForm.value.panNumber || null,
@@ -1976,7 +2108,105 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
return;
}
if (!this.clientId) {
this.showPanErrorAlert('Client must be created first. Complete General tab and click Next.');
return;
}
this.cinVerifying = true;
this.startCinProgress();
// The CIN has to be on the record before the command runs: the backend reads it off the KYC row
// (and rejects with "CIN number is not set on this KYC record" otherwise) rather than taking it
// from the request. Same save-then-verify order as PAN and GSTIN.
const payload: any = {
locale: 'en',
kycType: this.kycTypeValue,
riskCategory: this.currentRiskCategory,
cinNumber: cin,
dateFormat: this.settingsService.dateFormat,
panNumber: this.kycForm.value.enterprisePan || this.kycForm.value.panNumber || null,
aadhaarNumberMasked: 'XXXXXXXX0000'
};
this.saveKycAndDo(
payload,
() => {
this.clientsService.verifyCIN(this.clientId, this.kycId).subscribe({
next: (res: any) => {
this.stopCinProgress();
const changes = res?.changes || res;
if (changes?.cinVerified === true) {
this.cinVerifyStatus = 'VERIFIED';
// The label distinguishes a CIN the registry actually matched from one accepted on
// its structure alone, so the card never claims an MCA confirmation that never came.
this.cinCompanyName =
changes?.cinCompanyName ||
(changes?.cinConfirmedByRegistry === false ? 'Format validated' : 'Verified via MCA');
this.cinUnconfirmedMessage =
changes?.cinConfirmedByRegistry === false ? changes?.cinVerificationMessage || '' : '';
const rawDate = changes?.cinVerifiedOn;
if (Array.isArray(rawDate)) {
this.cinVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
} else {
const now = new Date();
this.cinVerifiedOn =
rawDate ||
`${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
}
} else {
this.markCinUnconfirmed(changes?.cinVerificationMessage);
}
},
error: (err: any) => {
this.stopCinProgress();
const message =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
'CIN verification failed.';
// A 503 means the provider could not be reached, which is worth interrupting the officer
// for — it is likely to succeed on a retry. Anything else is the provider having
// answered without confirming the company, which is not something a retry fixes and not
// something the officer can correct, so it is reported in place instead of as an alert.
if (err?.status === 503) {
this.cinVerifyStatus = 'FAILED';
this.showPanErrorAlert(message);
} else {
this.markCinUnconfirmed(message);
}
}
});
},
(msg) => {
this.stopCinProgress();
this.showPanErrorAlert(msg);
}
);
}
/**
* The CIN is stored and well-formed, but MCA did not confirm it.
*
* Reported in place rather than as an error dialog because there is nothing for the officer to
* correct: the number is already saved and the provider simply has no record to match it against.
* Deliberately NOT recorded as verified — `cinVerified` stays false on the KYC record, so the
* distinction between a confirmed and an unconfirmed CIN survives into the client's history.
*/
private markCinUnconfirmed(message?: string): void {
this.cinVerifyStatus = 'UNCONFIRMED';
this.cinUnconfirmedMessage =
message || 'MCA could not confirm this CIN. The number has been saved against the client.';
}
/**
* Drives the stepped MCA progress display while the verification is in flight. Deliberately stops
* short of the last step and of 100%: the real outcome comes from the server, and letting the bar
* finish on its own is what used to make an unverified CIN look verified.
*/
private startCinProgress(): void {
if (this.cinProgressTimer) {
clearInterval(this.cinProgressTimer);
}
this.cinProgressValue = 0;
this.cinCurrentStep = 0;
@@ -2000,22 +2230,23 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
break;
}
}
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 100);
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 90);
if (elapsed >= totalDuration) {
clearInterval(this.cinProgressTimer);
this.cinCurrentStep = stepDurations.length;
setTimeout(() => {
this.cinVerifying = false;
this.cinVerifyStatus = 'VERIFIED';
this.cinCompanyName = 'Verified via MCA';
const now = new Date();
this.cinVerifiedOn = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
}, 300);
this.cinProgressTimer = null;
}
}, interval);
}
private stopCinProgress(): void {
if (this.cinProgressTimer) {
clearInterval(this.cinProgressTimer);
this.cinProgressTimer = null;
}
this.cinProgressValue = 100;
this.cinVerifying = false;
}
/** Send Aadhaar OTP — saves Aadhaar-only KYC data, then sends OTP */
sendAadhaarOtp() {
if (!this.clientId) {
@@ -2041,8 +2272,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
const aadhaarMasked = 'XXXXXXXX' + aadhaar.slice(-4);
const aadhaarPayload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
kycType: this.kycTypeValue,
riskCategory: this.currentRiskCategory,
aadhaarNumberMasked: aadhaarMasked
};
const panVal = this.kycForm.value.panNumber;
@@ -2203,7 +2434,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
}
getRenewalYears(): number {
switch (this.kycForm.value.riskCategory) {
switch (this.currentRiskCategory) {
case 1:
return 10;
case 2:
@@ -2387,12 +2618,16 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
return {
locale: 'en',
dateFormat,
kycType: 1,
kycType: this.kycTypeValue,
riskCategory: v.riskCategory || 1,
// For non-individual: Company PAN is used as panNumber for verification
panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null,
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
panAadhaarSeedingStatus: this.panAadhaarSeedingStatus || null,
// Only ever sent when this screen actually holds a value. The seeding status is derived by
// the provider during PAN verification and written straight to the record, so sending an
// empty one back would blank a field the server filled in — which reads as an officer
// editing a verified record rather than the no-op it is.
...(this.panAadhaarSeedingStatus ? { panAadhaarSeedingStatus: this.panAadhaarSeedingStatus } : {}),
// PAN declaration — stored so the waived exception can be justified later, and so a reload
// does not silently drop the paperwork that is holding the exception off.
panDeclarationTaken: this.panDeclarationTaken,
@@ -2470,6 +2705,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
v.gstinNumber ||
v.enterprisePan ||
v.cinNumber ||
v.incorporationDate ||
v.kycDocumentName ||
v.form15Applicable ||
v.isException
@@ -442,18 +442,18 @@
<span class="preview-value mono">{{ kycData.enterprisePan }}</span>
</div>
}
@if (kycData.incorporationDate) {
<div class="preview-field">
<span class="preview-label">Date of Incorporation</span>
<span class="preview-value">{{ kycData.incorporationDate }}</span>
</div>
}
@if (kycData.cinNumber) {
<div class="preview-field">
<span class="preview-label">CIN (Corporate Identity Number)</span>
<span class="preview-value mono">{{ kycData.cinNumber }}</span>
</div>
}
@if (kycData.incorporationDate) {
<div class="preview-field">
<span class="preview-label">Incorporation Date</span>
<span class="preview-value">{{ kycData.incorporationDate | dateFormat }}</span>
</div>
}
@if (kycData.gstinNumber) {
<div class="preview-field">
<span class="preview-label">GSTIN Number</span>
+10
View File
@@ -62,6 +62,7 @@ import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component';
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
import { AuthorizedSignatoriesTabComponent } from './clients-view/authorized-signatories-tab/authorized-signatories-tab.component';
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
const routes: Routes = [
@@ -199,6 +200,15 @@ const routes: Routes = [
component: Form121TabComponent,
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
},
{
path: 'authorized-signatories',
component: AuthorizedSignatoriesTabComponent,
data: {
title: 'Authorized Signatories',
breadcrumb: 'Authorized Signatories',
routeParamBreadcrumb: false
}
},
{
path: 'documents',
component: DocumentsTabComponent,
@@ -0,0 +1,95 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="as-view">
@if (loading) {
<div class="as-loading">
<mat-progress-spinner mode="indeterminate" diameter="40"></mat-progress-spinner>
<p>Loading authorized signatories...</p>
</div>
}
@if (!loading) {
<div class="as-header">
<h3><fa-icon icon="user-shield"></fa-icon> Authorized Signatories</h3>
@if (signatories.length) {
<span class="as-count">{{ signatories.length }} on record</span>
}
</div>
<!-- The tab link is entity-only, but the route can be reached directly by URL. -->
@if (!isNonIndividual) {
<div class="as-empty">
<mat-icon>person</mat-icon>
<span>Authorized signatories apply to non-individual clients only.</span>
</div>
} @else if (!signatories.length) {
<div class="as-empty">
<mat-icon>group_off</mat-icon>
<span>No authorized signatories recorded for this entity.</span>
</div>
} @else {
<p class="as-note">
The persons authorised to transact on this entity's behalf, as named in its Board Resolution / Authorization
Letter.
</p>
@for (signatory of signatories; track $index) {
<div class="as-card">
<div class="as-card__main">
<div class="as-card__avatar"><mat-icon>person</mat-icon></div>
<div class="as-card__identity">
<span class="as-card__name">{{ signatory.name || 'Unnamed signatory' }}</span>
<span class="as-card__meta">
@if (signatory.loading) {
Loading details…
} @else {
@if (signatory.cifNumber) {
<span class="as-mono">CIF {{ signatory.cifNumber }}</span>
}
@if (signatory.officeName) {
<span class="as-card__dot">•</span>{{ signatory.officeName }}
}
@if (signatory.status) {
<span class="as-card__dot">•</span>{{ signatory.status }}
}
<!-- A signatory recorded as a name only — nothing to link to, and saying so is
better than an empty row the officer cannot explain. -->
@if (!signatory.clientId) {
<span class="as-card__unlinked">Name on record only — no linked client</span>
}
}
</span>
</div>
</div>
<div class="as-card__side">
<div class="as-din">
<span class="as-din__label">DIN</span>
@if (signatory.dinNumber) {
<span class="as-din__value as-mono">{{ signatory.dinNumber }}</span>
@if (signatory.dinVerified) {
<span class="as-chip as-chip--ok"><mat-icon>check_circle</mat-icon> Verified</span>
} @else {
<span class="as-chip as-chip--pending"><mat-icon>radio_button_unchecked</mat-icon> Unverified</span>
}
} @else {
<span class="as-din__value as-din__value--none">—</span>
}
</div>
@if (signatory.clientId) {
<a mat-stroked-button class="as-link-btn" [routerLink]="['/clients', signatory.clientId, 'general']">
<mat-icon>open_in_new</mat-icon> View client
</a>
}
</div>
</div>
}
}
}
</div>
@@ -0,0 +1,206 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.as-view {
padding: 16px 0;
}
.as-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px;
color: #64748b;
}
.as-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
h3 {
font-size: 16px;
font-weight: 600;
color: #1e293b;
display: flex;
align-items: center;
gap: 8px;
fa-icon {
color: #1565c0;
}
}
}
.as-count {
font-size: 12px;
font-weight: 600;
color: #059669;
background: #ecfdf5;
padding: 3px 10px;
border-radius: 12px;
}
.as-note {
margin: 0 0 16px;
font-size: 13px;
color: #64748b;
}
.as-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 40px;
color: #94a3b8;
font-size: 14px;
background: #f8fafc;
border: 2px dashed #e2e8f0;
border-radius: 12px;
}
.as-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 14px 18px;
margin-bottom: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
&__main {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
&__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 50%;
background: #eaf2fb;
color: #1565c0;
}
&__identity {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
&__name {
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
&__meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 12px;
color: #64748b;
}
&__dot {
color: #cbd5e1;
}
&__unlinked {
font-style: italic;
color: #94a3b8;
}
&__side {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
}
.as-din {
display: flex;
align-items: center;
gap: 8px;
&__label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
color: #94a3b8;
}
&__value {
font-size: 13px;
color: #1e293b;
&--none {
color: #94a3b8;
}
}
}
.as-mono {
font-family: 'Roboto Mono', 'Courier New', monospace;
}
.as-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
mat-icon {
font-size: 13px;
width: 13px;
height: 13px;
}
&--ok {
background: #ecfdf5;
color: #047857;
border: 1px solid #a7f3d0;
}
&--pending {
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
}
}
.as-link-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
}
}
@@ -0,0 +1,137 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
// MatAnchor, not MatButton: the link to the signatory's client record is an <a>, and the shared
// imports only carry the <button> directive.
import { MatAnchor } from '@angular/material/button';
import { ClientsService } from '../../clients.service';
import { LegalFormId } from '../../models/legal-form.enum';
/** One row of the tab — a name from the entity's record, enriched from the linked client. */
interface AuthorizedSignatory {
/** Client id of the individual, when the signatory was linked to a real client record. */
clientId: number | null;
name: string;
cifNumber: string;
officeName: string;
status: string;
dinNumber: string;
dinVerified: boolean;
/** Details are still being fetched for this row. */
loading: boolean;
}
@Component({
selector: 'mifosx-authorized-signatories-tab',
templateUrl: './authorized-signatories-tab.component.html',
styleUrls: ['./authorized-signatories-tab.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatIcon,
MatProgressSpinner,
MatAnchor
]
})
export class AuthorizedSignatoriesTabComponent implements OnInit {
private route = inject(ActivatedRoute);
private clientsService = inject(ClientsService);
clientId = '';
loading = true;
isNonIndividual = false;
signatories: AuthorizedSignatory[] = [];
ngOnInit(): void {
this.clientId = this.route.parent?.snapshot.paramMap.get('clientId') || '';
// Read from the parent route's resolved data — the entity's own record already carries the
// signatory names and ids, so the tab needs no request of its own to render its rows. Same
// subscription style as the other client tabs, which keeps this correct when the view is
// re-entered for a different client on a reused component instance.
this.route.parent?.data.subscribe((data: any) => {
const clientViewData = data?.clientViewData;
this.isNonIndividual = clientViewData?.legalForm?.id === LegalFormId.ENTITY;
const nonPerson = clientViewData?.clientNonPersonDetails;
this.signatories = this.buildRows(nonPerson?.authorizedSignatory, nonPerson?.authorizedSignatoryClientIds);
this.loading = false;
// DIN lives on the signatory's own KYC record, not on the entity's — the same place the
// General step reads it back from — so each linked row is filled in from there.
this.signatories.filter((s) => s.clientId != null).forEach((s) => this.loadSignatoryDetails(s));
});
}
/**
* Names and client ids are stored as two comma-separated columns written in the same order
* (see ClientGeneralStepComponent.syncSignatoryNames), so they are zipped back by position.
* A name with no id behind it is still shown: signatories recorded before linking existed, or
* typed in without a client record, are part of the entity's mandate and must not disappear.
*/
private buildRows(names: string | null | undefined, clientIds: string | null | undefined): AuthorizedSignatory[] {
const splitCsv = (value: string | null | undefined): string[] =>
(value || '')
.split(',')
.map((part) => part.trim())
.filter(Boolean);
const nameList = splitCsv(names);
const idList = splitCsv(clientIds);
// An id column longer than the name column would otherwise drop the extra links silently.
const rowCount = Math.max(nameList.length, idList.length);
return Array.from({ length: rowCount }, (_unused, index) => {
const rawId = idList[index];
const parsedId = rawId ? Number(rawId) : NaN;
const clientId = Number.isFinite(parsedId) ? parsedId : null;
return {
clientId,
name: nameList[index] || '',
cifNumber: '',
officeName: '',
status: '',
dinNumber: '',
dinVerified: false,
loading: clientId != null
};
});
}
private loadSignatoryDetails(signatory: AuthorizedSignatory): void {
const id = signatory.clientId!.toString();
this.clientsService.getClientData(id).subscribe({
next: (data: any) => {
signatory.name = signatory.name || data?.displayName || '';
signatory.cifNumber = data?.cifNumber || data?.accountNo || '';
signatory.officeName = data?.officeName || '';
signatory.status = data?.status?.value || '';
signatory.loading = false;
},
// A signatory whose client record cannot be read still shows as a row with its stored name,
// rather than spinning forever.
error: () => {
signatory.loading = false;
}
});
this.clientsService.getClientKyc(id).subscribe({
next: (kyc: any) => {
signatory.dinNumber = kyc?.dinNumber || '';
signatory.dinVerified = kyc?.dinVerified === true;
},
error: () => {}
});
}
}
@@ -554,6 +554,22 @@
>
<i class="fa fa-percent cv-tab-icon"></i> Form 121 / TDS
</a>
<!--
Authorized signatories are an entity concept — the persons named in a Board Resolution as
authorised to transact for the company. They are captured in the General step at onboarding
but have never been readable anywhere in the client view until now.
-->
@if (clientViewData.legalForm?.id === 2) {
<a
mat-tab-link
[routerLink]="['./authorized-signatories']"
routerLinkActive
#signatoriesTab="routerLinkActive"
[active]="signatoriesTab.isActive"
>
<i class="fa fa-user-shield cv-tab-icon"></i> Authorized Signatories
</a>
}
<a
mat-tab-link
[routerLink]="['./documents']"
@@ -11,9 +11,21 @@
<div>
<mifosx-file-upload flex="60%" (change)="onFileSelect($event)" acceptFilter=".png,.jpeg,.jpg"></mifosx-file-upload>
@if (imagePreview) {
<div class="image-preview">
<span class="image-preview__label">Preview</span>
<img class="image-preview__img" [src]="imagePreview" alt="Selected profile image preview" />
<span class="image-preview__name">{{ image.name }}</span>
</div>
}
@if (previewError) {
<div class="image-preview__error">{{ previewError }}</div>
}
<mat-dialog-actions align="end">
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
<button mat-raised-button color="primary" [disabled]="!image" [mat-dialog-close]="image">
<button mat-raised-button color="primary" [disabled]="!image || !imagePreview" [mat-dialog-close]="image">
{{ 'labels.buttons.Confirm' | translate }}
</button>
</mat-dialog-actions>
@@ -5,3 +5,45 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.image-preview {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin: 12px 0 4px;
}
.image-preview__label {
align-self: flex-start;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
color: rgb(0 0 0 / 60%);
}
.image-preview__img {
/* Capped in vh as well as px so a tall portrait photo cannot push the Cancel/Confirm footer
below the fold on a short viewport — `contain` keeps the whole frame visible rather than
cropping it, since the point of the preview is to check the picture before it goes live. */
max-width: 100%;
max-height: min(260px, 40vh);
object-fit: contain;
border-radius: 10px;
background: rgb(0 0 0 / 4%);
border: 1px solid rgb(0 0 0 / 12%);
}
.image-preview__name {
font-size: 12px;
color: rgb(0 0 0 / 60%);
overflow-wrap: anywhere;
text-align: center;
}
.image-preview__error {
margin: 12px 0 4px;
font-size: 13px;
color: #b3261e;
}
@@ -33,6 +33,16 @@ export class UploadImageDialogComponent {
/** Client Image */
image: File;
/**
* Data URL of the selected image, rendered as a preview so the officer sees what they picked
* before confirming — the upload replaces the client's profile picture immediately on confirm,
* and picking the wrong file is otherwise only discovered once it is already live.
*/
imagePreview: string | null = null;
/** Set when the picked file is not a readable image, in place of a preview. */
previewError = '';
/**
* Sets file form control value.
* @param {any} $event file change event.
@@ -40,6 +50,29 @@ export class UploadImageDialogComponent {
onFileSelect($event: any) {
if ($event.target.files.length > 0) {
this.image = $event.target.files[0];
this.buildPreview(this.image);
}
}
/** Reads the picked file into a data URL for the preview. */
private buildPreview(file: File) {
this.imagePreview = null;
this.previewError = '';
// The picker's accept filter is a hint the OS dialog can be talked out of, so re-check here
// rather than handing a non-image to FileReader and rendering a broken <img>.
if (!file.type?.startsWith('image/')) {
this.previewError = 'Selected file is not an image. Choose a PNG or JPEG file.';
return;
}
const reader = new FileReader();
reader.onload = () => {
this.imagePreview = reader.result as string;
};
reader.onerror = () => {
this.previewError = 'Could not read the selected file.';
};
reader.readAsDataURL(file);
}
}
@@ -104,221 +104,230 @@
<span class="kv-table__col kv-table__col--action">Action</span>
</div>
<!-- PAN Row -->
<div class="kv-table__row">
<span class="kv-table__col kv-table__col--doc">
<mat-icon class="kv-doc-icon">credit_card</mat-icon> PAN
</span>
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.panNumber || '—' }}</span>
<span class="kv-table__col kv-table__col--status">
@if (kycData.panVerified) {
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (panDeclarationTaken) {
<!-- Deliberately not "Verified": the check never passed, paperwork covers it. -->
<span class="kv-status kv-status--declared"><mat-icon>assignment_turned_in</mat-icon> Declaration</span>
} @else {
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.panVerified
? kycData.panVerifiedOn
? formatDate(kycData.panVerifiedOn)
: '—'
: panDeclarationTaken && kycData.panDeclarationTakenOn
? formatDate(kycData.panDeclarationTakenOn)
: '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="verifying.pan || !kycData.panNumber"
(click)="verifyPAN()"
>
@if (verifying.pan) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
<!--
PAN and Aadhaar below are the individual's own OVDs. An entity holds neither: its PAN is
the Enterprise PAN reported further down (the same stored panNumber, under the name the
entity actually knows it by), and Aadhaar is issued to people, not companies — the masked
number on a non-individual record is a placeholder the KYC step writes to satisfy the
column, so showing it as "Pending" invited officers to chase a document that cannot exist.
-->
@if (!isNonIndividual) {
<!-- PAN Row -->
<div class="kv-table__row">
<span class="kv-table__col kv-table__col--doc">
<mat-icon class="kv-doc-icon">credit_card</mat-icon> PAN
</span>
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.panNumber || '—' }}</span>
<span class="kv-table__col kv-table__col--status">
@if (kycData.panVerified) {
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (panDeclarationTaken) {
<!-- Deliberately not "Verified": the check never passed, paperwork covers it. -->
<span class="kv-status kv-status--declared"><mat-icon>assignment_turned_in</mat-icon> Declaration</span>
} @else {
Re-Verify
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</button>
<!--
</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.panVerified
? kycData.panVerifiedOn
? formatDate(kycData.panVerifiedOn)
: '—'
: panDeclarationTaken && kycData.panDeclarationTakenOn
? formatDate(kycData.panDeclarationTakenOn)
: '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="verifying.pan || !kycData.panNumber"
(click)="verifyPAN()"
>
@if (verifying.pan) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Re-Verify
}
</button>
<!--
A PAN that has never passed is not always fixable by another attempt. Offer the
declaration here too, so an existing client sitting in the exception list can be
cleared without the officer having to run a verification they know will fail.
-->
@if (!kycData.panVerified && !panDeclarationTaken && kycData.panNumber) {
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="!kycId || saving"
(click)="takePanDeclaration()"
>
Declaration
</button>
}
</span>
</div>
@if (!kycData.panVerified && !panDeclarationTaken && kycData.panNumber) {
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="!kycId || saving"
(click)="takePanDeclaration()"
>
Declaration
</button>
}
</span>
</div>
<!--
<!--
PAN is unverified but a declaration is on file. Shown in full because it is the only
reason PAN carries no KYC exception — a reviewer has to see what was declared.
-->
@if (!kycData.panVerified && panDeclarationTaken) {
<div class="kv-decl">
<div class="kv-decl__head">
<mat-icon>assignment_turned_in</mat-icon>
<span class="kv-decl__title">PAN Declaration</span>
@if (!saving) {
<span class="kv-decl__actions">
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takePanDeclaration()">
Edit
</button>
<button
mat-stroked-button
type="button"
class="kv-action-btn danger"
(click)="removePanDeclaration()"
>
Remove
</button>
</span>
}
</div>
<div class="kv-decl__rows">
<div class="kv-decl__row">
<span class="k">Does not match</span>
<span class="v strong">{{ panDeclarationMismatchLabel }}</span>
@if (!kycData.panVerified && panDeclarationTaken) {
<div class="kv-decl">
<div class="kv-decl__head">
<mat-icon>assignment_turned_in</mat-icon>
<span class="kv-decl__title">PAN Declaration</span>
@if (!saving) {
<span class="kv-decl__actions">
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takePanDeclaration()">
Edit
</button>
<button
mat-stroked-button
type="button"
class="kv-action-btn danger"
(click)="removePanDeclaration()"
>
Remove
</button>
</span>
}
</div>
@if (kycData.panDeclarationNameOnPan) {
<div class="kv-decl__rows">
<div class="kv-decl__row">
<span class="k">Name on PAN</span>
<span class="v">{{ kycData.panDeclarationNameOnPan }}</span>
<span class="k">Does not match</span>
<span class="v strong">{{ panDeclarationMismatchLabel }}</span>
</div>
}
@if (kycData.panDeclarationDobOnPan) {
<div class="kv-decl__row">
<span class="k">DOB on PAN</span>
<span class="v">{{ formatDate(kycData.panDeclarationDobOnPan) }}</span>
</div>
}
@if (kycData.panDeclarationRemarks) {
<div class="kv-decl__row">
<span class="k">Declaration file</span>
<span class="v">{{ kycData.panDeclarationRemarks }}</span>
</div>
}
@if (kycData.panDeclarationNameOnPan) {
<div class="kv-decl__row">
<span class="k">Name on PAN</span>
<span class="v">{{ kycData.panDeclarationNameOnPan }}</span>
</div>
}
@if (kycData.panDeclarationDobOnPan) {
<div class="kv-decl__row">
<span class="k">DOB on PAN</span>
<span class="v">{{ formatDate(kycData.panDeclarationDobOnPan) }}</span>
</div>
}
@if (kycData.panDeclarationRemarks) {
<div class="kv-decl__row">
<span class="k">Declaration file</span>
<span class="v">{{ kycData.panDeclarationRemarks }}</span>
</div>
}
</div>
<div class="kv-decl__note">
PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file.
</div>
</div>
<div class="kv-decl__note">
PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file.
</div>
</div>
}
}
<!-- Aadhaar Row -->
<div class="kv-table__row">
<span class="kv-table__col kv-table__col--doc">
<mat-icon class="kv-doc-icon">fingerprint</mat-icon> Aadhaar
</span>
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.aadhaarNumberMasked || '—' }}</span>
<span class="kv-table__col kv-table__col--status">
@if (kycData.aadhaarVerified) {
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (aadhaarReceiptTaken) {
<!-- Deliberately not "Verified": no Aadhaar exists yet, the slip covers it. -->
<span class="kv-status kv-status--receipt"><mat-icon>receipt_long</mat-icon> Receipt</span>
} @else {
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.aadhaarVerified
? kycData.aadhaarVerifiedOn
? formatDate(kycData.aadhaarVerifiedOn)
: '—'
: aadhaarReceiptTaken && kycData.aadhaarReceiptTakenOn
? formatDate(kycData.aadhaarReceiptTakenOn)
: '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="verifying.aadhaar || !kycData.aadhaarNumberMasked"
(click)="startAadhaarReVerify()"
>
Re-Verify
</button>
}
<!--
<!-- Aadhaar Row -->
<div class="kv-table__row">
<span class="kv-table__col kv-table__col--doc">
<mat-icon class="kv-doc-icon">fingerprint</mat-icon> Aadhaar
</span>
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.aadhaarNumberMasked || '—' }}</span>
<span class="kv-table__col kv-table__col--status">
@if (kycData.aadhaarVerified) {
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (aadhaarReceiptTaken) {
<!-- Deliberately not "Verified": no Aadhaar exists yet, the slip covers it. -->
<span class="kv-status kv-status--receipt"><mat-icon>receipt_long</mat-icon> Receipt</span>
} @else {
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.aadhaarVerified
? kycData.aadhaarVerifiedOn
? formatDate(kycData.aadhaarVerifiedOn)
: '—'
: aadhaarReceiptTaken && kycData.aadhaarReceiptTakenOn
? formatDate(kycData.aadhaarReceiptTakenOn)
: '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="verifying.aadhaar || !kycData.aadhaarNumberMasked"
(click)="startAadhaarReVerify()"
>
Re-Verify
</button>
}
<!--
No Aadhaar number means the OTP check has nothing to run against. Offer the enrolment
slip here so an existing client sitting in the exception list can be cleared.
-->
@if (!kycData.aadhaarVerified && !aadhaarReceiptTaken) {
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="!kycId || saving"
(click)="takeAadhaarReceipt()"
>
Receipt
</button>
}
</span>
</div>
@if (!kycData.aadhaarVerified && !aadhaarReceiptTaken) {
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="!kycId || saving"
(click)="takeAadhaarReceipt()"
>
Receipt
</button>
}
</span>
</div>
<!--
<!--
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
only reason Aadhaar carries no KYC exception.
-->
@if (!kycData.aadhaarVerified && aadhaarReceiptTaken) {
<div class="kv-rcpt">
<div class="kv-rcpt__head">
<mat-icon>receipt_long</mat-icon>
<span class="kv-rcpt__title">Aadhaar Enrolment Receipt</span>
@if (!saving) {
<span class="kv-rcpt__actions">
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takeAadhaarReceipt()">
Edit
</button>
<button
mat-stroked-button
type="button"
class="kv-action-btn danger"
(click)="removeAadhaarReceipt()"
>
Remove
</button>
</span>
}
</div>
<div class="kv-rcpt__rows">
<div class="kv-rcpt__row">
<span class="k">Enrolment ID</span>
<span class="v mono">{{ kycData.aadhaarEnrolmentId || '—' }}</span>
@if (!kycData.aadhaarVerified && aadhaarReceiptTaken) {
<div class="kv-rcpt">
<div class="kv-rcpt__head">
<mat-icon>receipt_long</mat-icon>
<span class="kv-rcpt__title">Aadhaar Enrolment Receipt</span>
@if (!saving) {
<span class="kv-rcpt__actions">
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takeAadhaarReceipt()">
Edit
</button>
<button
mat-stroked-button
type="button"
class="kv-action-btn danger"
(click)="removeAadhaarReceipt()"
>
Remove
</button>
</span>
}
</div>
@if (kycData.aadhaarEnrolmentDate) {
<div class="kv-rcpt__rows">
<div class="kv-rcpt__row">
<span class="k">Enrolment date</span>
<span class="v">{{ formatDate(kycData.aadhaarEnrolmentDate) }}</span>
<span class="k">Enrolment ID</span>
<span class="v mono">{{ kycData.aadhaarEnrolmentId || '—' }}</span>
</div>
}
@if (kycData.aadhaarReceiptRemarks) {
<div class="kv-rcpt__row">
<span class="k">Remarks</span>
<span class="v">{{ kycData.aadhaarReceiptRemarks }}</span>
</div>
}
@if (kycData.aadhaarEnrolmentDate) {
<div class="kv-rcpt__row">
<span class="k">Enrolment date</span>
<span class="v">{{ formatDate(kycData.aadhaarEnrolmentDate) }}</span>
</div>
}
@if (kycData.aadhaarReceiptRemarks) {
<div class="kv-rcpt__row">
<span class="k">Remarks</span>
<span class="v">{{ kycData.aadhaarReceiptRemarks }}</span>
</div>
}
</div>
<div class="kv-rcpt__note">
Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file.
</div>
</div>
<div class="kv-rcpt__note">
Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file.
</div>
</div>
}
}
<!--
@@ -425,23 +434,59 @@
</div>
}
<!-- Enterprise PAN Row (Non-Individual) -->
@if (isNonIndividual && kycData?.enterprisePan) {
<!--
Enterprise PAN Row (Non-Individual) — the entity's only PAN, so it stands in for the PAN
row hidden above and carries that row's verification state and Re-Verify action.
Status comes from panVerified, not from an enterprisePanVerified flag: the record has no
such field (ClientKycData holds enterprisePan as a bare string), and the KYC step stores
the entity PAN in panNumber and verifies it through the same verify-pan command. Reading
the flag that does not exist left a verified entity PAN permanently showing "Pending".
-->
@if (isNonIndividual && enterprisePanNumber) {
<div class="kv-table__row">
<span class="kv-table__col kv-table__col--doc">
<mat-icon class="kv-doc-icon">credit_card</mat-icon> Enterprise PAN
</span>
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.enterprisePan }}</span>
<span class="kv-table__col kv-table__col--num kv-mono">{{ enterprisePanNumber }}</span>
<span class="kv-table__col kv-table__col--status">
@if (kycData.enterprisePanVerified) {
@if (kycData.panVerified) {
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
} @else {
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.enterprisePanVerifiedOn ? formatDate(kycData.enterprisePanVerifiedOn) : '—'
kycData.panVerified && kycData.panVerifiedOn ? formatDate(kycData.panVerifiedOn) : '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="verifying.pan"
(click)="verifyPAN()"
>
@if (verifying.pan) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Re-Verify
}
</button>
</span>
</div>
}
<!-- Date of Incorporation Row (Non-Individual) — captured alongside the entity PAN
it is verified against, so it is reported here rather than under personal data. -->
@if (isNonIndividual && kycData?.incorporationDate) {
<div class="kv-table__row">
<span class="kv-table__col kv-table__col--doc">
<mat-icon class="kv-doc-icon">event_available</mat-icon> Date of Incorporation
</span>
<span class="kv-table__col kv-table__col--num">{{ formatDate(kycData.incorporationDate) }}</span>
<span class="kv-table__col kv-table__col--status">—</span>
<span class="kv-table__col kv-table__col--date">—</span>
<span class="kv-table__col kv-table__col--action"></span>
</div>
}
@@ -464,68 +509,61 @@
<span class="kv-table__col kv-table__col--action"></span>
</div>
}
<!-- Incorporation Date Row (Non-Individual) -->
@if (isNonIndividual && kycData?.incorporationDate) {
<div class="kv-table__row kv-table__row--link">
<span class="kv-table__col kv-table__col--doc">
<mat-icon class="kv-doc-icon">event</mat-icon> Incorporation Date
</span>
<span class="kv-table__col kv-table__col--num">{{ formatDate(kycData.incorporationDate) }}</span>
<span class="kv-table__col kv-table__col--status">—</span>
<span class="kv-table__col kv-table__col--date">—</span>
<span class="kv-table__col kv-table__col--action"></span>
</div>
}
</div>
<!-- ─── PAN ↔ Aadhaar Link — premium status card (replaces the legacy
table row). Dark gradient base with state-driven accent so the
linking state is the first thing a reviewer sees. -->
<div
class="pal-card"
[class.pal-card--ok]="kycData?.panAadhaarSeedingStatus === 'Y'"
[class.pal-card--fail]="kycData?.panAadhaarSeedingStatus === 'N'"
[class.pal-card--info]="kycData?.panAadhaarSeedingStatus === 'E'"
[class.pal-card--pending]="
kycData?.panAadhaarSeedingStatus !== 'Y' &&
kycData?.panAadhaarSeedingStatus !== 'N' &&
kycData?.panAadhaarSeedingStatus !== 'E'
"
>
<div class="pal-card__left">
<div class="pal-card__icon">
<mat-icon>credit_card</mat-icon>
</div>
<div class="pal-card__txt">
<div class="pal-card__small">Verification Status</div>
<div class="pal-card__title">PAN &harr; Aadhaar</div>
<div class="pal-card__status">
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
<mat-icon>check_circle</mat-icon> Linked (Seeded)
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
<mat-icon>cancel</mat-icon> Not Linked
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
<mat-icon>info</mat-icon> Exempt
} @else {
<mat-icon>radio_button_unchecked</mat-icon> Pending
}
linking state is the first thing a reviewer sees.
Section 139AA seeding applies to a person's PAN and Aadhaar. An entity has no Aadhaar
to seed, so for a non-individual the card could only ever read "Pending" against a
requirement that does not apply to it. -->
@if (!isNonIndividual) {
<div
class="pal-card"
[class.pal-card--ok]="kycData?.panAadhaarSeedingStatus === 'Y'"
[class.pal-card--fail]="kycData?.panAadhaarSeedingStatus === 'N'"
[class.pal-card--info]="kycData?.panAadhaarSeedingStatus === 'E'"
[class.pal-card--pending]="
kycData?.panAadhaarSeedingStatus !== 'Y' &&
kycData?.panAadhaarSeedingStatus !== 'N' &&
kycData?.panAadhaarSeedingStatus !== 'E'
"
>
<div class="pal-card__left">
<div class="pal-card__icon">
<mat-icon>credit_card</mat-icon>
</div>
<div class="pal-card__txt">
<div class="pal-card__small">Verification Status</div>
<div class="pal-card__title">PAN &harr; Aadhaar</div>
<div class="pal-card__status">
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
<mat-icon>check_circle</mat-icon> Linked (Seeded)
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
<mat-icon>cancel</mat-icon> Not Linked
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
<mat-icon>info</mat-icon> Exempt
} @else {
<mat-icon>radio_button_unchecked</mat-icon> Pending
}
</div>
</div>
</div>
<div class="pal-card__badge">
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
<mat-icon>check</mat-icon>
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
<mat-icon>close</mat-icon>
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
<mat-icon>info</mat-icon>
} @else {
<mat-icon>schedule</mat-icon>
}
</div>
<div class="pal-card__hint">As per Income Tax Portal &mdash; Section 139AA</div>
</div>
<div class="pal-card__badge">
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
<mat-icon>check</mat-icon>
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
<mat-icon>close</mat-icon>
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
<mat-icon>info</mat-icon>
} @else {
<mat-icon>schedule</mat-icon>
}
</div>
<div class="pal-card__hint">As per Income Tax Portal &mdash; Section 139AA</div>
</div>
}
<!-- Aadhaar OTP inline (shown below table when re-verifying) -->
@if (showAadhaarReVerify && !aadhaarOtpSent) {
@@ -795,15 +833,19 @@
<mat-card-content>
<form [formGroup]="kycForm" (ngSubmit)="saveKyc()">
<div class="kyc-edit-grid">
<!-- PAN -->
<!-- PAN — the same stored panNumber either way, named for whose PAN it is. -->
<div class="kyc-edit-field">
<div class="kyc-field-label-row">
<span class="kyc-field-title">{{ 'labels.inputs.PAN Number' | translate }}</span>
<span class="kyc-field-title">{{
isNonIndividual ? 'Enterprise PAN' : ('labels.inputs.PAN Number' | translate)
}}</span>
<span class="kyc-tag">KYC</span>
</div>
<div class="kyc-input-row">
<mat-form-field appearance="outline" class="kyc-input">
<mat-label>{{ 'labels.inputs.PAN Number' | translate }}</mat-label>
<mat-label>{{
isNonIndividual ? 'Enterprise PAN' : ('labels.inputs.PAN Number' | translate)
}}</mat-label>
<input
matInput
formControlName="panNumber"
@@ -852,147 +894,154 @@
The check can fail for reasons no retry will fix — a PAN card that spells the
name differently, or a vendor that is simply down. The declaration is the way
out, so it is offered here rather than leaving the officer at a dead end.
It records a mismatch against the customer's own name and date of birth, so it
is offered to individuals only — an entity PAN is checked against neither.
-->
<div class="kyc-decl-offer">
<span>Customer's PAN card genuinely carries different details, or PAN cannot be checked?</span>
<button
mat-stroked-button
type="button"
[disabled]="!kycId || saving"
(click)="takePanDeclaration()"
>
{{ panDeclarationTaken ? 'Edit Declaration' : 'Take Declaration' }}
</button>
</div>
@if (!isNonIndividual) {
<div class="kyc-decl-offer">
<span>Customer's PAN card genuinely carries different details, or PAN cannot be checked?</span>
<button
mat-stroked-button
type="button"
[disabled]="!kycId || saving"
(click)="takePanDeclaration()"
>
{{ panDeclarationTaken ? 'Edit Declaration' : 'Take Declaration' }}
</button>
</div>
}
}
@if (verificationStatus.pan === 'VERIFIED' && verificationDetails.pan?.name) {
<span class="verification-detail-text">{{ verificationDetails.pan.name }}</span>
}
</div>
<!-- Aadhaar -->
<div class="kyc-edit-field">
<div class="kyc-field-label-row">
<span class="kyc-field-title">{{ 'labels.inputs.Aadhaar Number' | translate }}</span>
<span class="kyc-tag">KYC</span>
</div>
<div class="kyc-input-row">
<mat-form-field appearance="outline" class="kyc-input">
<mat-label>{{ 'labels.inputs.Aadhaar Number' | translate }}</mat-label>
<input
matInput
formControlName="aadhaarNumber"
maxlength="12"
inputmode="numeric"
autocomplete="off"
placeholder="Enter 12-digit Aadhaar"
[readonly]="aadhaarOtpSent"
/>
@if (kycData?.aadhaarNumberMasked) {
<mat-hint>On file: {{ kycData.aadhaarNumberMasked }}</mat-hint>
}
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
<mat-error>{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}</mat-error>
}
@if (verificationStatus.aadhaar === 'VERIFIED') {
<mat-icon matSuffix class="status-icon verified">check_circle</mat-icon>
}
@if (verificationStatus.aadhaar === 'FAILED') {
<mat-icon matSuffix class="status-icon failed">cancel</mat-icon>
}
</mat-form-field>
@if (!aadhaarOtpSent) {
<button
mat-raised-button
color="accent"
type="button"
class="verify-btn"
[disabled]="
!kycId ||
!kycForm.get('aadhaarNumber')?.value ||
kycForm.get('aadhaarNumber')?.invalid ||
verifying.aadhaar
"
(click)="sendAadhaarOtp()"
[matTooltip]="!kycId ? 'Save KYC details first' : ''"
>
@if (verifying.aadhaar) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
Send OTP
<!-- Aadhaar — individuals only; an entity has no Aadhaar to capture or verify. -->
@if (!isNonIndividual) {
<div class="kyc-edit-field">
<div class="kyc-field-label-row">
<span class="kyc-field-title">{{ 'labels.inputs.Aadhaar Number' | translate }}</span>
<span class="kyc-tag">KYC</span>
</div>
<div class="kyc-input-row">
<mat-form-field appearance="outline" class="kyc-input">
<mat-label>{{ 'labels.inputs.Aadhaar Number' | translate }}</mat-label>
<input
matInput
formControlName="aadhaarNumber"
maxlength="12"
inputmode="numeric"
autocomplete="off"
placeholder="Enter 12-digit Aadhaar"
[readonly]="aadhaarOtpSent"
/>
@if (kycData?.aadhaarNumberMasked) {
<mat-hint>On file: {{ kycData.aadhaarNumberMasked }}</mat-hint>
}
</button>
}
</div>
<!-- OTP Section in Edit Mode -->
@if (aadhaarOtpSent) {
<div class="otp-section">
<p class="otp-info">
<mat-icon class="otp-info-icon">sms</mat-icon>
OTP sent to Aadhaar-linked mobile
</p>
<div class="otp-inline">
<mat-form-field appearance="outline" class="otp-field">
<mat-label>Enter OTP</mat-label>
<input
matInput
[(ngModel)]="aadhaarOtp"
[ngModelOptions]="{ standalone: true }"
maxlength="6"
autocomplete="one-time-code"
/>
</mat-form-field>
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
<mat-error>{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}</mat-error>
}
@if (verificationStatus.aadhaar === 'VERIFIED') {
<mat-icon matSuffix class="status-icon verified">check_circle</mat-icon>
}
@if (verificationStatus.aadhaar === 'FAILED') {
<mat-icon matSuffix class="status-icon failed">cancel</mat-icon>
}
</mat-form-field>
@if (!aadhaarOtpSent) {
<button
mat-raised-button
color="accent"
class="otp-verify-btn"
type="button"
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
(click)="verifyAadhaarOtp()"
class="verify-btn"
[disabled]="
!kycId ||
!kycForm.get('aadhaarNumber')?.value ||
kycForm.get('aadhaarNumber')?.invalid ||
verifying.aadhaar
"
(click)="sendAadhaarOtp()"
[matTooltip]="!kycId ? 'Save KYC details first' : ''"
>
@if (aadhaarOtpVerifying) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
@if (verifying.aadhaar) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
Verify OTP
Send OTP
}
</button>
<button
mat-stroked-button
type="button"
class="otp-resend-btn"
[disabled]="verifying.aadhaar"
(click)="sendAadhaarOtp()"
>
Resend
</button>
</div>
}
</div>
}
@if (verificationError.aadhaar) {
<div class="kyc-error-msg">
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
<span>{{ verificationError.aadhaar }}</span>
</div>
<!--
<!-- OTP Section in Edit Mode -->
@if (aadhaarOtpSent) {
<div class="otp-section">
<p class="otp-info">
<mat-icon class="otp-info-icon">sms</mat-icon>
OTP sent to Aadhaar-linked mobile
</p>
<div class="otp-inline">
<mat-form-field appearance="outline" class="otp-field">
<mat-label>Enter OTP</mat-label>
<input
matInput
[(ngModel)]="aadhaarOtp"
[ngModelOptions]="{ standalone: true }"
maxlength="6"
autocomplete="one-time-code"
/>
</mat-form-field>
<button
mat-raised-button
color="accent"
class="otp-verify-btn"
type="button"
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
(click)="verifyAadhaarOtp()"
>
@if (aadhaarOtpVerifying) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Verify OTP
}
</button>
<button
mat-stroked-button
type="button"
class="otp-resend-btn"
[disabled]="verifying.aadhaar"
(click)="sendAadhaarOtp()"
>
Resend
</button>
</div>
</div>
}
@if (verificationError.aadhaar) {
<div class="kyc-error-msg">
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
<span>{{ verificationError.aadhaar }}</span>
</div>
<!--
A failed OTP is not always something a retry can fix — a customer who has only
applied for Aadhaar has no number to verify at all. The enrolment slip is the way
out, so it is offered here rather than leaving the officer at a dead end.
-->
<div class="kyc-decl-offer">
<span>Customer has applied for Aadhaar but not received it yet?</span>
<button
mat-stroked-button
type="button"
[disabled]="!kycId || saving"
(click)="takeAadhaarReceipt()"
>
{{ aadhaarReceiptTaken ? 'Edit Receipt' : 'Record Receipt' }}
</button>
</div>
}
</div>
<div class="kyc-decl-offer">
<span>Customer has applied for Aadhaar but not received it yet?</span>
<button
mat-stroked-button
type="button"
[disabled]="!kycId || saving"
(click)="takeAadhaarReceipt()"
>
{{ aadhaarReceiptTaken ? 'Edit Receipt' : 'Record Receipt' }}
</button>
</div>
}
</div>
}
<!-- GSTIN (Non-Individual only) -->
@if (isNonIndividual) {
@@ -221,6 +221,17 @@ export class KycTabComponent implements OnInit, OnDestroy {
return this.kycData?.panDeclarationTaken === true;
}
/**
* The entity's PAN, for the Enterprise PAN row a non-individual gets in place of the PAN row.
*
* Falls back to panNumber because that is where the KYC step actually stores it — the entity PAN
* is written to both fields on create, but a record saved before `enterprisePan` existed, or one
* saved from a screen that only writes panNumber, would otherwise show no PAN at all.
*/
get enterprisePanNumber(): string {
return this.kycData?.enterprisePan || this.kycData?.panNumber || '';
}
/** "Name", "Date of Birth", "Name and Date of Birth", or the not-checked-at-all case. */
get panDeclarationMismatchLabel(): string {
const fields: string = this.kycData?.panDeclarationMismatchFields;
@@ -222,6 +222,14 @@
<div class="pd-field__value monospace">{{ clientViewData.clientNonPersonDetails.incorpNumber }}</div>
</div>
}
@if (clientViewData.clientNonPersonDetails.dateOfRegistration) {
<div class="pd-field">
<div class="pd-field__label">{{ 'labels.inputs.Incorporation Date' | translate }}</div>
<div class="pd-field__value">
{{ clientViewData.clientNonPersonDetails.dateOfRegistration | dateFormat: 'DD-MM-YYYY' }}
</div>
</div>
}
@if (clientViewData.clientNonPersonDetails.remarks) {
<div class="pd-field full-width">
<div class="pd-field__label">{{ 'labels.inputs.Remarks' | translate }}</div>
+9
View File
@@ -915,6 +915,15 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-gstin`, {});
}
/**
* Verifies CIN against MCA (reads the CIN from the stored KYC record, so it must be saved first).
* @param clientId Client ID
* @param kycId KYC record ID
*/
verifyCIN(clientId: string, kycId: string): Observable<any> {
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-cin`, {});
}
/**
* Verifies DIN (Director Identification Number) for an authorized signatory.
* Sends signatory clientId, DIN number and the non-individual entity name.
@@ -384,7 +384,8 @@ export class CreateClientComponent {
this.dialog
.open(VerifyClientAlertDialogComponent, {
width: '420px',
autoFocus: false
autoFocus: false,
data: { isEntity: this.legalFormType === 2 }
})
.afterClosed()
.subscribe(() => {
@@ -1159,8 +1160,8 @@ export class SaveConfirmDialogComponent {}
</div>
<div class="vcad-body">
<p>
Please click <strong>Verify Client</strong> to screen the client name against the UN Security Council
Consolidated Sanctions List before proceeding.
Please click <strong>{{ data?.isEntity ? 'Verify Entity' : 'Verify Client' }}</strong> to screen the client
name against the UN Security Council Consolidated Sanctions List before proceeding.
</p>
<div class="vcad-info">
<mat-icon class="vcad-info__icon">gavel</mat-icon>
@@ -1269,4 +1270,6 @@ export class SaveConfirmDialogComponent {}
MatDialogClose
]
})
export class VerifyClientAlertDialogComponent {}
export class VerifyClientAlertDialogComponent {
data = inject(MAT_DIALOG_DATA);
}
@@ -215,11 +215,18 @@
</div>
}
<div class="tab-section">
<!-- constitutionMeta and entityName are bound here exactly as they are in create-client.
Without them an entity opened for edit falls back to the generic document list rather
than the one its constitution actually uses, so the document type saved at creation
can be missing from the picker; a proprietorship is also mis-rendered, since the
proprietor-PAN branch keys off the constitution. -->
<mifosx-client-kyc-step
#clientKyc
[isNonIndividual]="legalFormType === 2"
[isMinor]="clientGeneralStep?.isMinor || false"
[clientId]="clientId"
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
[verificationName]="clientGeneralStep?.verificationName || ''"
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
[readOnly]="isTabReadOnly(1)"
@@ -40,6 +40,7 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../shared/client-edit-diff.util';
import { toProperNameCase } from '../utils/name-case.util';
/**
* Edit Client Component — uses the same wizard/stepper layout as Create Client.
@@ -589,13 +590,11 @@ export class EditClientComponent {
const kycId = this.clientKycStep.kycId;
const isVerified = this.clientKycStep.isKycVerified;
// Validate remarks are present for verified KYC
if (isVerified && !kycData.remarks?.trim()) {
this.savingClient = false;
this.clientKycStep.kycForm.get('remarks')?.markAsTouched();
return;
}
// Remarks are not demanded here. The server requires them only when a save actually
// overwrites data the verified record already held, and it names the reason when it
// refuses. Blocking every verified record up front aborted the save silently — the photo
// upload above had already gone through, so the image changed, the button came back to
// life, and nothing said why nothing had been saved.
let saveObs;
if (kycId && isVerified) {
// VERIFIED → re-edit command
@@ -628,8 +627,19 @@ export class EditClientComponent {
this.saveKycDocProof(this.clientId);
this.selectedTabIndex = this.selectedTabIndex + 1;
},
error: () => {
// A rejected save used to just re-enable the button, which reads as "nothing happened".
// The server's own message says what is missing — remarks on a re-edit, most often — so
// it is shown rather than swallowed, and the KYC tab stays put for the officer to fix.
error: (err: any) => {
this.savingClient = false;
const message =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
'KYC could not be saved. Please review the KYC details and try again.';
this.snackBar.open(message, 'Close', { duration: 6000 });
if (isVerified) {
this.clientKycStep?.kycForm.get('remarks')?.markAsTouched();
}
}
});
} else {
@@ -734,17 +744,20 @@ export class EditClientComponent {
? this.toLocalDateString(oldDobRaw)
: oldDobRaw || '';
// Both sides run through toProperNameCase so a casing-only difference — the stored
// 'LINCPAY LTD' against the normalized 'Lincpay LTD' the form now submits — is not
// mistaken for a real rename and does not force a PAN re-verification.
const buildName = (parts: { firstname?: string; middlename?: string; lastname?: string; fullname?: string }) =>
(
toProperNameCase(
parts.fullname ||
[
parts.firstname,
parts.middlename,
parts.lastname
]
.filter((p) => p && p.trim())
.join(' ')
).trim();
[
parts.firstname,
parts.middlename,
parts.lastname
]
.filter((p) => p && p.trim())
.join(' ')
);
const newName = buildName({
firstname: form?.get('firstname')?.value || '',
@@ -0,0 +1,88 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { ENTITY_KYC_CATEGORY, ENTITY_KYC_DOCUMENT_TYPES, withEntityKycDocuments } from './entity-kyc-documents';
describe('withEntityKycDocuments', () => {
const names = (docs: { name: string }[]) => docs.map((d) => d.name);
it('keeps the six RBI entity documents when nothing else is supplied', () => {
expect(names(withEntityKycDocuments())).toEqual(names(ENTITY_KYC_DOCUMENT_TYPES));
});
it('puts the entity documents ahead of anything the caller adds', () => {
const merged = withEntityKycDocuments([{ name: 'Declaration by Karta', mandatory: true }], 'HUF');
expect(names(merged.slice(0, 6))).toEqual(names(ENTITY_KYC_DOCUMENT_TYPES));
expect(merged[6].name).toBe('Declaration by Karta');
});
it('groups the entity documents together and the rest under the constitution', () => {
const merged = withEntityKycDocuments([{ name: 'Declaration by Karta' }], 'HUF');
expect(merged[0].category).toBe(ENTITY_KYC_CATEGORY);
expect(merged[6].category).toBe('HUF documents');
});
it('folds the backend template names onto the entity chips instead of duplicating them', () => {
const merged = withEntityKycDocuments(
[
{ name: 'Certificate of Incorporation', code: 'COI', mandatory: true },
{ name: 'Memorandum of Association', code: 'MOA', mandatory: true },
{ name: 'Articles of Association', code: 'AOA', mandatory: true },
{ name: 'PAN Card of Company', code: 'PAN', mandatory: true },
{ name: 'Board Resolution', code: 'BOARD_RESOLUTION', mandatory: true },
{ name: 'KYC of Authorized Signatories', code: 'SIGNATORY_KYC', mandatory: true }
],
'Private Limited Company'
);
expect(merged).toHaveLength(6);
expect(names(merged)).toEqual(names(ENTITY_KYC_DOCUMENT_TYPES));
});
it('folds constitution labels on too, since those lists carry no codes', () => {
const merged = withEntityKycDocuments(
[
{ name: 'Certificate of Incorporation', code: 'Certificate of Incorporation', mandatory: true },
{ name: 'LLP Agreement', code: 'LLP Agreement', mandatory: true },
{ name: 'PAN of LLP', code: 'PAN of LLP', mandatory: true },
{ name: 'Designated Partners Resolution', code: 'Designated Partners Resolution', mandatory: true },
{ name: 'KYC of Designated Partners / Authorized Signatories', code: 'x', mandatory: true }
],
'Limited Liability Partnership (LLP)'
);
expect(merged).toHaveLength(6);
});
it('carries a mandatory flag over to the entity document it folds into', () => {
const merged = withEntityKycDocuments([{ name: 'PAN Card of Company', code: 'PAN', mandatory: true }]);
expect(merged.find((d) => d.code === 'ENTITY_PAN')?.mandatory).toBe(true);
expect(merged.find((d) => d.code === 'GST_CERTIFICATE')?.mandatory).toBe(false);
});
it('keeps a document that only mentions another one as its own requirement', () => {
const merged = withEntityKycDocuments(
[{ name: 'Proof of Business Existence (GST Certificate / Shop License)', mandatory: true }],
'Sole Proprietorship'
);
expect(names(merged)).toContain('Proof of Business Existence (GST Certificate / Shop License)');
});
it('keeps constitution-specific requirements the entity list does not cover', () => {
const merged = withEntityKycDocuments(
[
{ name: 'Government Notification / Order', mandatory: true },
{ name: 'Udyam Registration Certificate', code: 'UDYAM', mandatory: true },
{ name: 'Proof of Address of HUF' }],
'Government Body / PSU'
);
expect(names(merged).slice(6)).toEqual([
'Government Notification / Order',
'Udyam Registration Certificate',
'Proof of Address of HUF'
]);
});
});
@@ -0,0 +1,138 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Shape the KYC document-type picker works with. */
export interface KycDocumentType {
name: string;
code: string;
mandatory: boolean;
category?: string;
}
export const ENTITY_KYC_CATEGORY = 'Entity KYC documents';
/**
* The KYC documents every legal entity must produce, whatever its constitution — RBI Master
* Direction on KYC, Annexure I. Codes match PROOF_META in kyc-proof-types.ts, which is what
* groups them under "Entity KYC documents" and supplies each chip's banner text.
*
* This is the single source for the entity list so a non-individual client sees the same
* documents wherever the picker is rendered — the create stepper, the edit stepper, before a
* constitution has been chosen and after, and once the backend KYC template has loaded.
*/
export const ENTITY_KYC_DOCUMENT_TYPES: { name: string; code: string }[] = [
{ 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' }
];
/**
* Codes the backend KYC template uses for documents the entity list already covers
* (ClientKycApiResource.getRequiredDocuments). Folding them onto the entity codes keeps one
* chip per document instead of "PAN Card of Company" sitting next to "PAN Card of Entity".
*/
const ENTITY_DOC_CODE_ALIASES: Record<string, string> = {
CERT_INCORPORATION: 'CERT_INCORPORATION',
COI: 'CERT_INCORPORATION',
REG_CERT: 'CERT_INCORPORATION',
ENTITY_PAN: 'ENTITY_PAN',
PAN: 'ENTITY_PAN',
CONSTITUTIVE_DOC: 'CONSTITUTIVE_DOC',
MOA: 'CONSTITUTIVE_DOC',
AOA: 'CONSTITUTIVE_DOC',
PARTNERSHIP_DEED: 'CONSTITUTIVE_DOC',
TRUST_DEED: 'CONSTITUTIVE_DOC',
BOARD_RESOLUTION: 'BOARD_RESOLUTION',
AUTH_SIGNATORY_KYC: 'AUTH_SIGNATORY_KYC',
SIGNATORY_KYC: 'AUTH_SIGNATORY_KYC',
GST_CERTIFICATE: 'GST_CERTIFICATE',
GST_CERT: 'GST_CERTIFICATE'
};
/**
* The per-constitution lists carry no codes of their own (code === name), so those entries are
* matched on the label instead. Patterns are anchored deliberately: a document that merely
* mentions another one ("Proof of Business Existence (GST Certificate / Shop License)") is a
* distinct requirement and has to survive as its own chip rather than vanish into GST.
*/
const ENTITY_DOC_NAME_PATTERNS: { pattern: RegExp; code: string }[] = [
{ pattern: /^certificate of incorporation\b/, code: 'CERT_INCORPORATION' },
{ pattern: /^registration certificate\b/, code: 'CERT_INCORPORATION' },
{
pattern: /^(memorandum|articles of association|partnership deed|trust deed|llp agreement|by-?laws)\b/,
code: 'CONSTITUTIVE_DOC'
},
{ pattern: /^pan\b/, code: 'ENTITY_PAN' },
{ pattern: /^(board resolution|designated partners resolution|resolution authorizing)\b/, code: 'BOARD_RESOLUTION' },
{ pattern: /^(kyc of|authorized signatory kyc|proprietor'?s kyc)\b/, code: 'AUTH_SIGNATORY_KYC' },
{ pattern: /^gst (registration )?certificate\b/, code: 'GST_CERTIFICATE' }
];
/** The entity document this one duplicates, or null when it is a requirement of its own. */
function resolveEntityDocCode(doc: { name?: string; code?: string }): string | null {
const code = (doc.code || '').toUpperCase().trim();
// A constitution list stores the label in `code`, so only treat it as a code when it is one.
if (code && !code.includes(' ') && ENTITY_DOC_CODE_ALIASES[code]) {
return ENTITY_DOC_CODE_ALIASES[code];
}
const name = (doc.name || '').toLowerCase().trim();
return ENTITY_DOC_NAME_PATTERNS.find((p) => p.pattern.test(name))?.code || null;
}
/**
* Puts the RBI entity documents in front of whatever list a caller has (per-constitution docs,
* or the backend KYC template) instead of letting that list replace them.
*
* Without this the officer sees a different set of documents on every screen for the same
* client: the entity list until a constitution is picked, the constitution's own list after,
* and the backend template's list once the client exists and is reopened for edit. Anything in
* `extra` that isn't already one of the six stays, grouped under `extraCategory` — those are
* genuine constitution-specific requirements (Karta declaration, Udyam certificate, government
* notification) and dropping them would lose real requirements.
*
* A mandatory flag on a duplicate carries over to the entity chip it folds into, so the
* "at least one mandatory document" validator still sees it.
*/
export function withEntityKycDocuments(
extra: { name: string; code?: string; mandatory?: boolean }[] = [],
extraCategory?: string | null
): KycDocumentType[] {
const mandatoryCodes = new Set<string>();
const leftovers: KycDocumentType[] = [];
extra.forEach((doc) => {
const entityCode = resolveEntityDocCode(doc);
if (entityCode) {
if (doc.mandatory) {
mandatoryCodes.add(entityCode);
}
return;
}
leftovers.push({
name: doc.name,
code: doc.code || doc.name,
mandatory: !!doc.mandatory,
category: extraCategory ? `${extraCategory} documents` : 'Additional documents'
});
});
const base: KycDocumentType[] = ENTITY_KYC_DOCUMENT_TYPES.map((d) => ({
name: d.name,
code: d.code,
mandatory: mandatoryCodes.has(d.code),
category: ENTITY_KYC_CATEGORY
}));
return [
...base,
...leftovers
];
}
@@ -0,0 +1,55 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { toProperNameCase } from './name-case.util';
describe('toProperNameCase', () => {
it('raises a lower-case first letter on every word', () => {
expect(toProperNameCase('keshav Singh sisodiya')).toBe('Keshav Singh Sisodiya');
});
it('leaves the rest of each word exactly as typed', () => {
expect(toProperNameCase('lincpay lTD')).toBe('Lincpay LTD');
expect(toProperNameCase('lincpay pvt ltd')).toBe('Lincpay Pvt Ltd');
});
it('does not rewrite a name that is already correct', () => {
expect(toProperNameCase('Technoludium Solutions Private Limited')).toBe('Technoludium Solutions Private Limited');
});
it('leaves an upper-case name shouted rather than title-casing it', () => {
expect(toProperNameCase('TECHNOLUDIUM SOLUTIONS')).toBe('TECHNOLUDIUM SOLUTIONS');
expect(toProperNameCase('KESHAV singh')).toBe('KESHAV Singh');
});
it('capitalises after a hyphen', () => {
expect(toProperNameCase('jean-marie traders')).toBe('Jean-Marie Traders');
});
it('capitalises name prefixes after an apostrophe but not possessives', () => {
expect(toProperNameCase("o'brien d'souza")).toBe("O'Brien D'Souza");
expect(toProperNameCase("lincpay's agency")).toBe("Lincpay's Agency");
});
it('leaves punctuation and non-letters untouched', () => {
expect(toProperNameCase('abc pvt. ltd.')).toBe('Abc Pvt. Ltd.');
expect(toProperNameCase('tata & sons')).toBe('Tata & Sons');
expect(toProperNameCase('3m india')).toBe('3m India');
});
it('trims and collapses whitespace', () => {
expect(toProperNameCase(' lincpay holdings ')).toBe('Lincpay Holdings');
});
it('returns an empty string for blank or non-string input', () => {
expect(toProperNameCase('')).toBe('');
expect(toProperNameCase(' ')).toBe('');
expect(toProperNameCase(null)).toBe('');
expect(toProperNameCase(undefined)).toBe('');
});
});
+64
View File
@@ -0,0 +1,64 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* Casing for client names: the name is stored exactly as the officer typed it, except that a word
* starting with a lower-case letter has that first letter raised. Nothing else is touched.
*
* 'keshav Singh sisodiya' → 'Keshav Singh Sisodiya'
* 'lincpay lTD' → 'Lincpay LTD'
* 'Technoludium Solutions Private Limited' → unchanged
* 'TECHNOLUDIUM SOLUTIONS' → unchanged
*
* Deliberately NOT full title-casing. Lower-casing the rest of each word would rewrite an entity
* name the officer entered as it appears on the certificate — 'TECHNOLUDIUM' becoming
* 'Technoludium', 'Ltd' becoming a forced 'LTD'. That name is matched character-for-character
* against the PAN holder's registered name during KYC verification, so the typed spelling is the
* thing to preserve; only an unintended lower-case first letter is corrected.
*/
/** Characters after which a new word starts: 'jean-marie' → 'Jean-Marie'. */
const SEGMENT_STARTERS = new Set([
'-',
"'",
'’'
]);
/**
* Raises the first letter of each word in a single space-delimited token, leaving every other
* character as typed.
*/
function capitalizeWord(word: string): string {
let result = '';
for (let i = 0; i < word.length; i++) {
const previous = i === 0 ? '' : word[i - 1];
let startsWord = i === 0 || SEGMENT_STARTERS.has(previous);
if (previous === "'" || previous === '’') {
// 'o'brien' / 'd'souza' capitalise after the apostrophe, but a possessive
// ('lincpay's') must not — only a single leading letter marks the name prefix.
startsWord = i === 2;
}
result += startsWord ? word[i].toUpperCase() : word[i];
}
return result;
}
/**
* Applies the casing rule above to a person or entity name.
* Trims and collapses runs of whitespace; non-string input yields ''.
*/
export function toProperNameCase(value: string | null | undefined): string {
if (typeof value !== 'string') {
return '';
}
const collapsed = value.trim().replace(/\s+/g, ' ');
if (!collapsed) {
return '';
}
return collapsed.split(' ').map(capitalizeWord).join(' ');
}
@@ -7,556 +7,121 @@
-->
<div class="alm-dlg">
<!-- HEADER -->
<div class="alm-head">
<!-- Window chrome. Screen only — the print takes the statement's own heading, not this bar. -->
<div class="alm-head no-print">
<div class="alm-head-left">
<div class="alm-head-ic"><fa-icon icon="balance-scale"></fa-icon></div>
<div>
<h2 class="alm-title">Asset Liability Management — Structural Liquidity</h2>
<h2 class="alm-title">Average Liabilities and Assets</h2>
<p class="alm-sub">
{{ bank.name }} &middot; <strong>{{ bank.formCode }}</strong> &middot; {{ bank.quarter }} &middot; As on
<strong>{{ bank.asOnDate }}</strong>
{{ bank.name }} &middot; {{ branchName }} &middot; FY {{ bank.financialYear }} &middot; Month-wise during the
year
</p>
</div>
</div>
<div class="alm-head-actions">
<span class="alm-overall" [ngClass]="gap14dWithinTolerance && gap28dWithinTolerance ? 'on-target' : 'below'">
<span class="alm-overall-l">14d Tolerance</span>
<span class="alm-overall-v">{{ gap14dPct.toFixed(2) }}%</span>
<span class="alm-overall-t">/ -{{ bank.tolerance14d }}%</span>
</span>
<button class="alm-close" (click)="close()" matTooltip="Close" aria-label="Close">&#10005;</button>
<button class="alm-close" (click)="close()" matTooltip="Close" aria-label="Close">&#10005;</button>
</div>
<!-- Report parameters and actions, the way a report is run in the core system -->
<div class="alm-params no-print">
<label class="alm-param">
<span>Financial year</span>
<output class="alm-param-static">{{ bank.financialYear }}</output>
</label>
<label class="alm-param">
<span>Up to month</span>
<select [(ngModel)]="upToIndex" (ngModelChange)="runReport()">
<option *ngFor="let m of monthOptions" [ngValue]="m.index">{{ m.label }}</option>
</select>
</label>
<label class="alm-param">
<span>Months included</span>
<output class="alm-param-static">{{ statementRows.length }}</output>
</label>
<span class="alm-params-sp"></span>
<button type="button" class="alm-btn" (click)="print()"><fa-icon icon="print"></fa-icon>&nbsp; Print</button>
<button type="button" class="alm-btn" (click)="exportXls()">
<fa-icon icon="file-excel"></fa-icon>&nbsp; Export
</button>
</div>
<p class="alm-note no-print">
<fa-icon icon="info-circle"></fa-icon>&nbsp; <strong>{{ unmappedColumns }}</strong> are not mapped in the
core-system extract and print as 0.00. Total and Average cover the {{ statementRows.length }} month(s) shown.
</p>
<!-- ═══ THE STATEMENT — what goes on paper ═══ -->
<div class="alm-body">
<div class="stmt" #statement>
<div class="stmt-bank">{{ statementHeading }}</div>
<div class="stmt-title">Average Liabilities and Assets Month-wise during the year</div>
<div class="stmt-tbl-wrap">
<table class="stmt-tbl">
<thead>
<tr>
<th rowspan="2" class="col-month">Month</th>
<th rowspan="2">Capital</th>
<th rowspan="2">Reserve/Prov<br />Exclude OIR</th>
<th rowspan="2">Deposits</th>
<th rowspan="2">Borrowings</th>
<th rowspan="2">Other Liabilities</th>
<th rowspan="2">Cash on Hand</th>
<th colspan="2" class="col-grp">Bank Balances</th>
<th rowspan="2">Investments<br />including</th>
<th rowspan="2">Advances</th>
<th rowspan="2">Other Assets<br />Exclude IRA</th>
</tr>
<tr>
<th>Current</th>
<th>FDs</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let r of statementRows">
<td class="col-month">
<strong>{{ r.label }}</strong
><br /><span class="stmt-yr">{{ r.sub }}</span>
</td>
<td>{{ r.capital | number: '1.2-2' }}</td>
<td>{{ r.reserveProv | number: '1.2-2' }}</td>
<td>{{ r.deposits | number: '1.2-2' }}</td>
<td>{{ r.borrowings | number: '1.2-2' }}</td>
<td>{{ r.otherLiabilities | number: '1.2-2' }}</td>
<td>{{ r.cashOnHand | number: '1.2-2' }}</td>
<td>{{ r.bankCurrent | number: '1.2-2' }}</td>
<td>{{ r.bankFds | number: '1.2-2' }}</td>
<td>{{ r.investments | number: '1.2-2' }}</td>
<td>{{ r.advances | number: '1.2-2' }}</td>
<td>{{ r.otherAssets | number: '1.2-2' }}</td>
</tr>
<tr class="stmt-sum" *ngFor="let r of [totalRow, averageRow]">
<td class="col-month">{{ r.label }}</td>
<td>{{ r.capital | number: '1.2-2' }}</td>
<td>{{ r.reserveProv | number: '1.2-2' }}</td>
<td>{{ r.deposits | number: '1.2-2' }}</td>
<td>{{ r.borrowings | number: '1.2-2' }}</td>
<td>{{ r.otherLiabilities | number: '1.2-2' }}</td>
<td>{{ r.cashOnHand | number: '1.2-2' }}</td>
<td>{{ r.bankCurrent | number: '1.2-2' }}</td>
<td>{{ r.bankFds | number: '1.2-2' }}</td>
<td>{{ r.investments | number: '1.2-2' }}</td>
<td>{{ r.advances | number: '1.2-2' }}</td>
<td>{{ r.otherAssets | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="stmt-foot">
<div>Print Date : {{ generatedAt }}</div>
<div>Report Ref No : {{ bank.reportRefNo }}&nbsp;&nbsp;User Code:{{ statementUserCode }}</div>
</div>
</div>
</div>
<!-- TABS -->
<div class="alm-tabs">
<button class="alm-tab" [class.active]="activeTab === 'dashboard'" (click)="setTab('dashboard')">
<fa-icon icon="chart-pie"></fa-icon>&nbsp; Dashboard
</button>
<button class="alm-tab" [class.active]="activeTab === 'ladder'" (click)="setTab('ladder')">
<fa-icon icon="layer-group"></fa-icon>&nbsp; Maturity Ladder
</button>
<button class="alm-tab" [class.active]="activeTab === 'monthly'" (click)="setTab('monthly')">
<fa-icon icon="calendar-alt"></fa-icon>&nbsp; Monthly Snapshot
</button>
<button class="alm-tab" [class.active]="activeTab === 'avgbal'" (click)="setTab('avgbal')">
<fa-icon icon="table"></fa-icon>&nbsp; Avg. Liabilities &amp; Assets
</button>
<button class="alm-tab" [class.active]="activeTab === 'returns'" (click)="setTab('returns')">
<fa-icon icon="file-invoice"></fa-icon>&nbsp; Form ALM-1
</button>
</div>
<div class="alm-body">
<!-- ════════════ DASHBOARD ════════════ -->
<ng-container *ngIf="activeTab === 'dashboard'">
<div class="alm-stats">
<div class="alm-stat-card alm-stat-gauge">
<div
class="gauge"
[style.--pct.%]="totalGapPct > 0 ? (totalGapPct > 100 ? 100 : totalGapPct) : 0"
[ngClass]="totalGap >= 0 ? 'on-target' : 'below'"
>
<div class="gauge-inner">
<div class="gauge-pct">{{ totalGapPct.toFixed(1) }}%</div>
<div class="gauge-label">Net Gap / Outflow</div>
</div>
</div>
</div>
<div class="alm-stat-card">
<div class="alm-stat-l">NDTL</div>
<div class="alm-stat-v">&#8377; {{ bank.ndtl / 100 | number: '1.2-2' }} Cr</div>
<div class="alm-stat-meta">Net Demand &amp; Time Liabilities</div>
</div>
<div class="alm-stat-card">
<div class="alm-stat-l">Total Outflows</div>
<div class="alm-stat-v">&#8377; {{ totalOutflow / 100 | number: '1.2-2' }} Cr</div>
<div class="alm-stat-meta">All maturity buckets</div>
</div>
<div class="alm-stat-card">
<div class="alm-stat-l">Total Inflows</div>
<div class="alm-stat-v">&#8377; {{ totalInflow / 100 | number: '1.2-2' }} Cr</div>
<div class="alm-stat-meta" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
Net: {{ totalGap >= 0 ? '+' : '' }}{{ totalGap / 100 | number: '1.2-2' }} Cr
</div>
</div>
</div>
<!-- TOLERANCE BARS -->
<div class="alm-section">
<div class="alm-sec-h">
<h3 class="alm-sec-t">RBI Tolerance Limits — Cumulative Gap</h3>
<span class="alm-circular">RBI MD: {{ bank.rbiCircular }}</span>
</div>
<div class="alm-tolerance">
<div class="alm-tol-row" [ngClass]="gap14dWithinTolerance ? 'good' : 'bad'">
<div class="alm-tol-h">
<span class="alm-tol-l"
><fa-icon icon="hourglass-half"></fa-icon>&nbsp; Cumulative Gap up to 14 days</span
>
<span class="alm-tol-p">
<strong>{{ gap14dPct.toFixed(2) }}%</strong>
<span class="alm-tol-lim">limit -{{ bank.tolerance14d }}%</span>
<span class="alm-tol-st">{{ gap14dWithinTolerance ? 'WITHIN' : 'BREACH' }}</span>
</span>
</div>
<div class="alm-tol-track">
<div
class="alm-tol-fill"
[style.width.%]="gap14dPct >= 0 ? 100 : 100 + (gap14dPct / bank.tolerance14d) * 100"
></div>
<div class="alm-tol-marker"></div>
</div>
</div>
<div class="alm-tol-row" [ngClass]="gap28dWithinTolerance ? 'good' : 'bad'">
<div class="alm-tol-h">
<span class="alm-tol-l"><fa-icon icon="clock"></fa-icon>&nbsp; Cumulative Gap up to 30 days</span>
<span class="alm-tol-p">
<strong>{{ gap28dPct.toFixed(2) }}%</strong>
<span class="alm-tol-lim">limit -{{ bank.tolerance28d }}%</span>
<span class="alm-tol-st">{{ gap28dWithinTolerance ? 'WITHIN' : 'BREACH' }}</span>
</span>
</div>
<div class="alm-tol-track">
<div
class="alm-tol-fill"
[style.width.%]="gap28dPct >= 0 ? 100 : 100 + (gap28dPct / bank.tolerance28d) * 100"
></div>
<div class="alm-tol-marker"></div>
</div>
</div>
</div>
</div>
<!-- BUCKET MINI HEATMAP -->
<div class="alm-section">
<div class="alm-sec-h">
<h3 class="alm-sec-t">Bucket-wise Mismatch Heatmap</h3>
</div>
<div class="alm-heatmap">
<div
*ngFor="let b of buckets"
class="heat-cell"
[ngClass]="bucketStatus(b)"
[matTooltip]="b.label + ' — Gap: ' + bucketGapPct(b).toFixed(2) + '%'"
>
<div class="heat-code">{{ b.code }}</div>
<div class="heat-pct">{{ bucketGapPct(b).toFixed(1) }}%</div>
<div class="heat-lbl">{{ b.label }}</div>
</div>
</div>
</div>
<div class="alm-section">
<div class="alm-sec-h">
<h3 class="alm-sec-t">Reporting Snapshot</h3>
</div>
<div class="alm-info-grid">
<div class="alm-info">
<div class="alm-info-l">Reporting Bank</div>
<div class="alm-info-v">{{ bank.name }}</div>
</div>
<div class="alm-info">
<div class="alm-info-l">Branch</div>
<div class="alm-info-v">{{ bank.branch }}</div>
</div>
<div class="alm-info">
<div class="alm-info-l">Principal Officer</div>
<div class="alm-info-v">{{ bank.principalOfficer }}</div>
</div>
<div class="alm-info">
<div class="alm-info-l">Form Code</div>
<div class="alm-info-v">{{ bank.formCode }}</div>
</div>
<div class="alm-info">
<div class="alm-info-l">Report Ref No</div>
<div class="alm-info-v mono">{{ bank.reportRefNo }}</div>
</div>
<div class="alm-info">
<div class="alm-info-l">User Code</div>
<div class="alm-info-v mono">{{ bank.userCode }}</div>
</div>
</div>
</div>
</ng-container>
<!-- ════════════ MATURITY LADDER ════════════ -->
<ng-container *ngIf="activeTab === 'ladder'">
<div class="alm-section">
<div class="alm-sec-h">
<h3 class="alm-sec-t">Statement of Structural Liquidity — Maturity Ladder</h3>
<div class="alm-sec-actions">
<select class="alm-sel" [(ngModel)]="filterBucket">
<option value="">All Buckets</option>
<option value="short">Short Term (≤ 30 days)</option>
<option value="medium">Medium Term (1–12 months)</option>
<option value="long">Long Term (&gt; 1 year)</option>
</select>
<button mat-stroked-button class="alm-btn-xls" (click)="exportLadderCsv()">
<fa-icon icon="file-csv"></fa-icon>&nbsp; Export CSV
</button>
</div>
</div>
<div class="alm-tbl-wrap">
<table class="alm-tbl">
<thead>
<tr>
<th>Bucket</th>
<th>Time Period</th>
<th class="ta-r">Outflow (Liab)</th>
<th class="ta-r">Inflow (Assets)</th>
<th class="ta-r">Gap</th>
<th class="ta-r">Gap %</th>
<th class="ta-c">Status</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let b of filteredBuckets">
<td class="mono">{{ b.code }}</td>
<td>{{ b.label }}</td>
<td class="ta-r">{{ b.outflow | number: '1.2-2' }}</td>
<td class="ta-r">{{ b.inflow | number: '1.2-2' }}</td>
<td class="ta-r" [ngClass]="bucketGap(b) >= 0 ? 'pos' : 'neg'">
<strong>{{ bucketGap(b) >= 0 ? '+' : '' }}{{ bucketGap(b) | number: '1.2-2' }}</strong>
</td>
<td class="ta-r" [ngClass]="bucketGap(b) >= 0 ? 'pos' : 'neg'">
{{ bucketGapPct(b) >= 0 ? '+' : '' }}{{ bucketGapPct(b).toFixed(2) }}%
</td>
<td class="ta-c">
<span class="status-pill" [ngClass]="bucketStatus(b)">
<ng-container *ngIf="bucketStatus(b) === 'surplus'">SURPLUS</ng-container>
<ng-container *ngIf="bucketStatus(b) === 'deficit'">DEFICIT</ng-container>
<ng-container *ngIf="bucketStatus(b) === 'balanced'">BALANCED</ng-container>
</span>
</td>
</tr>
<tr class="row-total">
<td colspan="2"><strong>TOTAL</strong></td>
<td class="ta-r">
<strong>{{ totalOutflow | number: '1.2-2' }}</strong>
</td>
<td class="ta-r">
<strong>{{ totalInflow | number: '1.2-2' }}</strong>
</td>
<td class="ta-r" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
<strong>{{ totalGap >= 0 ? '+' : '' }}{{ totalGap | number: '1.2-2' }}</strong>
</td>
<td class="ta-r" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
<strong>{{ totalGapPct >= 0 ? '+' : '' }}{{ totalGapPct.toFixed(2) }}%</strong>
</td>
<td></td>
</tr>
</tbody>
</table>
</div>
<!-- Cumulative table -->
<h4 class="form-iii-t">Cumulative Position</h4>
<div class="alm-tbl-wrap">
<table class="alm-tbl">
<thead>
<tr>
<th>Through Bucket</th>
<th class="ta-r">Cum. Outflow</th>
<th class="ta-r">Cum. Inflow</th>
<th class="ta-r">Cum. Gap</th>
<th class="ta-r">Cum. Gap %</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let b of buckets; let i = index">
<td>{{ b.code }} — {{ b.label }}</td>
<td class="ta-r">{{ cumulativeOutflow(i) | number: '1.2-2' }}</td>
<td class="ta-r">{{ cumulativeInflow(i) | number: '1.2-2' }}</td>
<td class="ta-r" [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'">
{{ cumulativeGap(i) >= 0 ? '+' : '' }}{{ cumulativeGap(i) | number: '1.2-2' }}
</td>
<td class="ta-r" [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'">
<strong>{{ cumulativeGapPct(i) >= 0 ? '+' : '' }}{{ cumulativeGapPct(i).toFixed(2) }}%</strong>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</ng-container>
<!-- ════════════ MONTHLY ════════════ -->
<ng-container *ngIf="activeTab === 'monthly'">
<div class="alm-section">
<div class="alm-sec-h">
<h3 class="alm-sec-t">Monthly Asset / Liability Snapshot — FY {{ bank.fiscalYear }}</h3>
<button mat-stroked-button class="alm-btn-xls" (click)="exportMonthlyXls()">
<fa-icon icon="file-excel"></fa-icon>&nbsp; Export Excel
</button>
</div>
<div class="alm-tbl-wrap">
<table class="alm-tbl">
<thead>
<tr>
<th rowspan="2">Month</th>
<th colspan="4" class="ta-c grp-liab">Liabilities (₹ Lakh)</th>
<th colspan="4" class="ta-c grp-asset">Assets (₹ Lakh)</th>
</tr>
<tr>
<th class="ta-r">Reserve/Prov</th>
<th class="ta-r">Borrowings</th>
<th class="ta-r">FDs</th>
<th class="ta-r">Total Liab</th>
<th class="ta-r">Cash</th>
<th class="ta-r">Investments</th>
<th class="ta-r">Other Assets</th>
<th class="ta-r">Total Assets</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let m of monthly">
<td class="mono">{{ m.month }}</td>
<td class="ta-r">{{ m.reserveProv | number: '1.2-2' }}</td>
<td class="ta-r">{{ m.borrowings | number: '1.2-2' }}</td>
<td class="ta-r">{{ m.fds | number: '1.2-2' }}</td>
<td class="ta-r">
<strong>{{ monthTotalLiab(m) | number: '1.2-2' }}</strong>
</td>
<td class="ta-r">{{ m.cashOnHand | number: '1.2-2' }}</td>
<td class="ta-r">{{ m.investments | number: '1.2-2' }}</td>
<td class="ta-r">{{ m.otherAssets | number: '1.2-2' }}</td>
<td class="ta-r">
<strong>{{ monthTotalAssets(m) | number: '1.2-2' }}</strong>
</td>
</tr>
<tr class="row-total">
<td><strong>AVERAGE</strong></td>
<td class="ta-r">{{ avg('reserveProv') | number: '1.2-2' }}</td>
<td class="ta-r">{{ avg('borrowings') | number: '1.2-2' }}</td>
<td class="ta-r">{{ avg('fds') | number: '1.2-2' }}</td>
<td class="ta-r">
<strong>{{ avg('reserveProv') + avg('borrowings') + avg('fds') | number: '1.2-2' }}</strong>
</td>
<td class="ta-r">{{ avg('cashOnHand') | number: '1.2-2' }}</td>
<td class="ta-r">{{ avg('investments') | number: '1.2-2' }}</td>
<td class="ta-r">{{ avg('otherAssets') | number: '1.2-2' }}</td>
<td class="ta-r">
<strong>{{ avg('cashOnHand') + avg('investments') + avg('otherAssets') | number: '1.2-2' }}</strong>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</ng-container>
<!-- ════════════ AVERAGE LIABILITIES AND ASSETS ════════════ -->
<ng-container *ngIf="activeTab === 'avgbal'">
<div class="alm-section">
<div class="alm-sec-h">
<h3 class="alm-sec-t">Average Liabilities and Assets Month-wise during the year</h3>
<button mat-stroked-button class="alm-btn-xls" (click)="exportAvgBalancesXls()">
<fa-icon icon="file-excel"></fa-icon>&nbsp; Export Excel
</button>
</div>
<!-- Printed-statement banner: institution and office come off the session, not the report. -->
<div class="alm-stmt">
<div class="alm-stmt-bank">{{ statementHeading }}</div>
<div class="alm-stmt-title">Average Liabilities and Assets Month-wise during the year</div>
<div class="alm-tbl-wrap">
<table class="alm-tbl alm-stmt-tbl">
<thead>
<tr>
<th rowspan="2">Month</th>
<th rowspan="2" class="ta-r">Capital</th>
<th rowspan="2" class="ta-r">Reserve/Prov<br />Exclude OIR</th>
<th rowspan="2" class="ta-r">Deposits</th>
<th rowspan="2" class="ta-r">Borrowings</th>
<th rowspan="2" class="ta-r">Other Liabilities</th>
<th rowspan="2" class="ta-r">Cash on Hand</th>
<th colspan="2" class="ta-c">Bank Balances</th>
<th rowspan="2" class="ta-r">Investments<br />including</th>
<th rowspan="2" class="ta-r">Advances</th>
<th rowspan="2" class="ta-r">Other Assets<br />Exclude IRA</th>
</tr>
<tr>
<th class="ta-r">Current</th>
<th class="ta-r">FDs</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let r of avgBalanceRows">
<td class="mono">
<strong>{{ r.label }}</strong
><br /><span class="alm-stmt-sub">{{ r.sub }}</span>
</td>
<td class="ta-r">{{ r.capital | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.reserveProv | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.deposits | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.borrowings | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.otherLiabilities | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.cashOnHand | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.bankCurrent | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.bankFds | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.investments | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.advances | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.otherAssets | number: '1.2-2' }}</td>
</tr>
<tr class="row-total" *ngFor="let r of [avgBalanceTotal, avgBalanceAverage]">
<td>{{ r.label }}</td>
<td class="ta-r">{{ r.capital | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.reserveProv | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.deposits | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.borrowings | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.otherLiabilities | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.cashOnHand | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.bankCurrent | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.bankFds | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.investments | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.advances | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.otherAssets | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="alm-stmt-foot">
<div>Print Date : {{ statementPrintedAt }}</div>
<div>Report Ref No : {{ bank.reportRefNo }}&nbsp;&nbsp;User Code:{{ statementUserCode }}</div>
</div>
</div>
</div>
</ng-container>
<!-- ════════════ RETURNS / FORM ALM-1 ════════════ -->
<ng-container *ngIf="activeTab === 'returns'">
<div class="alm-section">
<div class="alm-sec-h">
<h3 class="alm-sec-t">{{ bank.formCode }} — Statement of Structural Liquidity</h3>
<button mat-stroked-button class="alm-btn-xls" (click)="exportFormAlm()">
<fa-icon icon="file-excel"></fa-icon>&nbsp; Generate {{ bank.formCode }}
</button>
</div>
<div class="form-iii">
<div class="form-iii-h">
<div class="form-iii-bank">
<strong>{{ bank.name }}</strong> &mdash; {{ bank.branch }}
</div>
<div class="form-iii-meta">
<span><fa-icon icon="calendar"></fa-icon>&nbsp; As On {{ bank.asOnDate }}</span>
<span><fa-icon icon="university"></fa-icon>&nbsp; {{ bank.quarter }}</span>
<span class="mono"><fa-icon icon="hashtag"></fa-icon>&nbsp; Ref: {{ bank.reportRefNo }}</span>
</div>
</div>
<h4 class="form-iii-t">A. NDTL &amp; Reference</h4>
<table class="alm-tbl form-tbl">
<tbody>
<tr>
<td>Net Demand &amp; Time Liabilities (NDTL) (₹ Lakhs)</td>
<td class="ta-r">
<strong>{{ bank.ndtl | number: '1.2-2' }}</strong>
</td>
</tr>
<tr>
<td>RBI Master Direction</td>
<td class="ta-r mono">{{ bank.rbiCircular }}</td>
</tr>
</tbody>
</table>
<h4 class="form-iii-t">B. Maturity Ladder (₹ in Lakhs)</h4>
<table class="alm-tbl form-tbl">
<thead>
<tr>
<th>Bucket</th>
<th>Time Period</th>
<th class="ta-r">Outflows</th>
<th class="ta-r">Inflows</th>
<th class="ta-r">Mismatch</th>
<th class="ta-r">Cum. Mismatch</th>
<th class="ta-r">Cum. % of Outflow</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let b of buckets; let i = index">
<td class="mono">{{ b.code }}</td>
<td>{{ b.label }}</td>
<td class="ta-r">{{ b.outflow | number: '1.2-2' }}</td>
<td class="ta-r">{{ b.inflow | number: '1.2-2' }}</td>
<td class="ta-r" [ngClass]="bucketGap(b) >= 0 ? 'pos' : 'neg'">
{{ bucketGap(b) >= 0 ? '+' : '' }}{{ bucketGap(b) | number: '1.2-2' }}
</td>
<td class="ta-r" [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'">
{{ cumulativeGap(i) >= 0 ? '+' : '' }}{{ cumulativeGap(i) | number: '1.2-2' }}
</td>
<td class="ta-r">
<strong [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'"
>{{ cumulativeGapPct(i) >= 0 ? '+' : '' }}{{ cumulativeGapPct(i).toFixed(2) }}%</strong
>
</td>
</tr>
<tr class="form-total">
<td colspan="2"><strong>TOTAL</strong></td>
<td class="ta-r">
<strong>{{ totalOutflow | number: '1.2-2' }}</strong>
</td>
<td class="ta-r">
<strong>{{ totalInflow | number: '1.2-2' }}</strong>
</td>
<td class="ta-r" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
<strong>{{ totalGap >= 0 ? '+' : '' }}{{ totalGap | number: '1.2-2' }}</strong>
</td>
<td colspan="2"></td>
</tr>
</tbody>
</table>
<h4 class="form-iii-t">C. RBI Tolerance Compliance</h4>
<table class="alm-tbl form-tbl">
<tbody>
<tr>
<td>Cumulative Negative Gap up to 14 days</td>
<td class="ta-r">
<strong>{{ gap14dPct.toFixed(2) }}%</strong>
</td>
<td class="ta-r">Limit: -{{ bank.tolerance14d }}%</td>
<td class="ta-c">
<span class="status-pill" [ngClass]="gap14dWithinTolerance ? 'on-target' : 'below'">
{{ gap14dWithinTolerance ? 'WITHIN' : 'BREACH' }}
</span>
</td>
</tr>
<tr>
<td>Cumulative Negative Gap up to 30 days</td>
<td class="ta-r">
<strong>{{ gap28dPct.toFixed(2) }}%</strong>
</td>
<td class="ta-r">Limit: -{{ bank.tolerance28d }}%</td>
<td class="ta-c">
<span class="status-pill" [ngClass]="gap28dWithinTolerance ? 'on-target' : 'below'">
{{ gap28dWithinTolerance ? 'WITHIN' : 'BREACH' }}
</span>
</td>
</tr>
</tbody>
</table>
<div class="form-iii-foot">
<div class="cert">
<div class="cert-l">Certified by</div>
<div class="cert-v">{{ bank.principalOfficer }}</div>
<div class="cert-r">Principal Officer — {{ bank.name }}</div>
</div>
<div class="cert">
<div class="cert-l">Submitted under</div>
<div class="cert-v mono">{{ bank.rbiCircular }}</div>
<div class="cert-r">RBI Master Direction on Asset Liability Management</div>
</div>
</div>
</div>
</div>
</ng-container>
</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { Component, ElementRef, ViewChild, inject } from '@angular/core';
import { CommonModule, DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
@@ -16,42 +16,34 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { BranchScopeService } from 'app/core/branch-scope.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
interface MaturityBucket {
code: string;
label: string;
/** Outflows / Liabilities (₹ Lakhs) */
outflow: number;
/** Inflows / Assets (₹ Lakhs) */
inflow: number;
}
interface MonthSnapshot {
/**
* One month of the average liabilities and assets extract, column for column as the core system
* prints it.
*/
interface MonthlyAlmRow {
/** e.g. 'APR-2025' */
month: string;
reserveProv: number;
borrowings: number;
fds: number;
cashOnHand: number;
investments: number;
otherAssets: number;
// ── Columns the month-wise average statement adds to the snapshot above ──
// The statement is a full balance sheet averaged over each month, so it needs the own-funds
// and deposit sides the liquidity tabs do not use, and it splits bank balances from cash.
/** Paid-up share capital. */
capital: number;
/** Total deposits — the FD component above is part of this figure. */
reserveProv: number;
/** Total deposits — demand and time. */
deposits: number;
borrowings: number;
otherLiabilities: number;
cashOnHand: number;
/** Balances with other banks, current accounts. */
bankCurrent: number;
/** Balances with other banks, held as fixed deposits. */
bankFds: number;
investments: number;
advances: number;
otherAssets: number;
}
/** One row of the month-wise average statement — a month, or the Total / Average line. */
interface AvgBalanceRow {
/** One printed line — a month, or the Total / Average line the statement closes with. */
interface StatementRow {
label: string;
/** Sub-label under the month, e.g. the year, as the printed statement prints it. */
/** Sub-label under the month, the year, as the printed statement sets it. */
sub: string;
capital: number;
reserveProv: number;
@@ -66,6 +58,18 @@ interface AvgBalanceRow {
otherAssets: number;
}
/**
* Average Liabilities and Assets Month-wise during the year.
*
* One report, one statement, the way the core system prints it — a parameter line, the statement,
* and the print footer. It is deliberately not a dashboard: an ALM screen in a bank is a report
* you run and print, not a set of tabs, and figures that go out over the bank's name have to look
* the same on screen as they do on paper.
*
* Month rows are the figures as extracted. Investments, Advances, Bank-FD balances and Other
* Assets are not mapped in the extract and print as 0.00 — stated on screen rather than filled in,
* because a nil that is really "not captured" must not read as a real balance.
*/
@Component({
selector: 'mifosx-alm-report-dialog',
standalone: true,
@@ -86,310 +90,226 @@ export class AlmReportDialogComponent {
private branchScope = inject(BranchScopeService);
private auth = inject(AuthenticationService);
activeTab: 'dashboard' | 'ladder' | 'monthly' | 'avgbal' | 'returns' = 'dashboard';
/** The rendered statement, handed to the print window as-is. */
@ViewChild('statement') private statementRef?: ElementRef<HTMLElement>;
bank = {
readonly bank = {
name: 'CBSNexora Bank Ltd',
branch: 'Head Office',
asOnDate: '31-03-2026',
quarter: 'Q4 FY 2025-26',
fiscalYear: '2025-26',
rbiCircular: 'DCBR.BPD.(PCB).Cir.No.4/12.05.001/2018-19',
principalOfficer: 'Karthik Kamath',
formCode: 'Form ALM-1',
financialYear: '2025-26',
reportRefNo: '1036',
userCode: 'CBSN01',
/** Net Demand & Time Liabilities — denominator for ratios */
ndtl: 740000, // ₹7,400 Cr in Lakhs
/** RBI tolerance: cumulative negative gap up to 14 days ≤ 5%, 28 days ≤ 10% */
tolerance14d: 5,
tolerance28d: 10
userCode: 'CBSN01'
};
/** Standard RBI ALM time buckets per Master Direction */
buckets: MaturityBucket[] = [
{ code: 'B01', label: 'Day 1', outflow: 1840, inflow: 2120 },
{ code: 'B02', label: '2 to 7 days', outflow: 6420, inflow: 5880 },
{ code: 'B03', label: '8 to 14 days', outflow: 4180, inflow: 4360 },
{ code: 'B04', label: '15 to 30 days', outflow: 8240, inflow: 7920 },
{ code: 'B05', label: '31 days to 2 months', outflow: 12480, inflow: 13160 },
{ code: 'B06', label: '> 2 months to 3 months', outflow: 9620, inflow: 10240 },
{ code: 'B07', label: '> 3 months to 6 months', outflow: 28640, inflow: 32180 },
{ code: 'B08', label: '> 6 months to 1 year', outflow: 64280, inflow: 68420 },
{ code: 'B09', label: '> 1 year to 3 years', outflow: 142800, inflow: 156400 },
{ code: 'B10', label: '> 3 years to 5 years', outflow: 96400, inflow: 108200 },
{ code: 'B11', label: 'Over 5 years', outflow: 124800, inflow: 138400 }
];
/** Monthly snapshot — mirrors the format of the legacy ALM Excel */
monthly: MonthSnapshot[] = [
/**
* The financial year, April to March. A part-year print simply stops at the month asked for, which
* is why `upToIndex` exists rather than the row list being cut to size here.
*/
monthly: MonthlyAlmRow[] = [
{
month: 'APR-2025',
reserveProv: 18420,
borrowings: 24180,
fds: 462800,
cashOnHand: 8420,
investments: 184600,
otherAssets: 12480,
capital: 24200,
deposits: 602800,
otherLiabilities: 28400,
bankCurrent: 18900,
bankFds: 46200,
advances: 372400
capital: 264.66,
reserveProv: 720.83,
deposits: 6108.63,
borrowings: 24.47,
otherLiabilities: 280.91,
cashOnHand: 172.44,
bankCurrent: 190.09,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'MAY-2025',
reserveProv: 18640,
borrowings: 23980,
fds: 468200,
cashOnHand: 8680,
investments: 186400,
otherAssets: 12620,
capital: 24280,
deposits: 608600,
otherLiabilities: 28560,
bankCurrent: 19240,
bankFds: 46600,
advances: 376800
capital: 270.73,
reserveProv: 722.11,
deposits: 6181.15,
borrowings: 26.36,
otherLiabilities: 280.91,
cashOnHand: 129.0,
bankCurrent: 218.27,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'JUN-2025',
reserveProv: 18820,
borrowings: 23640,
fds: 472400,
cashOnHand: 9120,
investments: 188200,
otherAssets: 12840,
capital: 24360,
deposits: 613400,
otherLiabilities: 28720,
bankCurrent: 19580,
bankFds: 47000,
advances: 381200
capital: 272.61,
reserveProv: 722.11,
deposits: 6291.01,
borrowings: 33.0,
otherLiabilities: 280.91,
cashOnHand: 115.01,
bankCurrent: 322.77,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'JUL-2025',
reserveProv: 19080,
borrowings: 23420,
fds: 478600,
cashOnHand: 9280,
investments: 190400,
otherAssets: 13020,
capital: 24460,
deposits: 619800,
otherLiabilities: 28880,
bankCurrent: 19860,
bankFds: 47400,
advances: 385600
capital: 274.06,
reserveProv: 755.72,
deposits: 6299.45,
borrowings: 50.07,
otherLiabilities: 280.91,
cashOnHand: 84.82,
bankCurrent: 267.13,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'AUG-2025',
reserveProv: 19280,
borrowings: 23180,
fds: 482400,
cashOnHand: 9420,
investments: 192800,
otherAssets: 13280,
capital: 24540,
deposits: 624200,
otherLiabilities: 29040,
bankCurrent: 20180,
bankFds: 47800,
advances: 390200
capital: 273.22,
reserveProv: 755.72,
deposits: 6404.97,
borrowings: 26.53,
otherLiabilities: 280.91,
cashOnHand: 111.77,
bankCurrent: 253.24,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'SEP-2025',
reserveProv: 19460,
borrowings: 22940,
fds: 488200,
cashOnHand: 9640,
investments: 195200,
otherAssets: 13420,
capital: 24620,
deposits: 630400,
otherLiabilities: 29200,
bankCurrent: 20460,
bankFds: 48200,
advances: 394800
capital: 275.34,
reserveProv: 755.72,
deposits: 6361.83,
borrowings: 30.3,
otherLiabilities: 280.91,
cashOnHand: 136.45,
bankCurrent: 190.83,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'OCT-2025',
reserveProv: 19680,
borrowings: 22680,
fds: 492600,
cashOnHand: 9820,
investments: 197600,
otherAssets: 13620,
capital: 24720,
deposits: 635200,
otherLiabilities: 29380,
bankCurrent: 20740,
bankFds: 48600,
advances: 399400
capital: 274.82,
reserveProv: 755.72,
deposits: 6304.25,
borrowings: 38.0,
otherLiabilities: 280.91,
cashOnHand: 93.77,
bankCurrent: 239.01,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'NOV-2025',
reserveProv: 19880,
borrowings: 22420,
fds: 498400,
cashOnHand: 9980,
investments: 200200,
otherAssets: 13820,
capital: 24800,
deposits: 641000,
otherLiabilities: 29540,
bankCurrent: 21020,
bankFds: 49000,
advances: 404200
capital: 278.29,
reserveProv: 755.72,
deposits: 6269.1,
borrowings: 52.64,
otherLiabilities: 280.91,
cashOnHand: 92.03,
bankCurrent: 215.31,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'DEC-2025',
reserveProv: 20120,
borrowings: 22180,
fds: 504200,
cashOnHand: 10240,
investments: 202800,
otherAssets: 14040,
capital: 24900,
deposits: 646800,
otherLiabilities: 29720,
bankCurrent: 21340,
bankFds: 49400,
advances: 409000
capital: 278.97,
reserveProv: 755.72,
deposits: 6310.57,
borrowings: 50.01,
otherLiabilities: 280.91,
cashOnHand: 98.58,
bankCurrent: 221.93,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'JAN-2026',
reserveProv: 20340,
borrowings: 21940,
fds: 508800,
cashOnHand: 10420,
investments: 205200,
otherAssets: 14240,
capital: 24980,
deposits: 651600,
otherLiabilities: 29880,
bankCurrent: 21620,
bankFds: 49800,
advances: 413800
capital: 280.14,
reserveProv: 755.72,
deposits: 6358.2,
borrowings: 47.85,
otherLiabilities: 280.91,
cashOnHand: 101.36,
bankCurrent: 228.47,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'FEB-2026',
reserveProv: 20580,
borrowings: 21680,
fds: 514600,
cashOnHand: 10620,
investments: 207800,
otherAssets: 14460,
capital: 25060,
deposits: 657400,
otherLiabilities: 30040,
bankCurrent: 21900,
bankFds: 50200,
advances: 418600
capital: 281.42,
reserveProv: 768.4,
deposits: 6402.75,
borrowings: 44.62,
otherLiabilities: 280.91,
cashOnHand: 104.19,
bankCurrent: 233.08,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'MAR-2026',
reserveProv: 20840,
borrowings: 21420,
fds: 520400,
cashOnHand: 10820,
investments: 210400,
otherAssets: 14680,
capital: 25160,
deposits: 663200,
otherLiabilities: 30220,
bankCurrent: 22240,
bankFds: 50600,
advances: 423600
capital: 283.05,
reserveProv: 768.4,
deposits: 6471.38,
borrowings: 41.3,
otherLiabilities: 280.91,
cashOnHand: 110.62,
bankCurrent: 241.66,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
}
];
filterBucket = '';
/**
* Last month included in the print. Defaults to the ninth month, DEC-2025, because that is where a
* statement taken part-way through the year stops — the months after it have not closed.
*/
upToIndex = 8;
// -------------------- Computed --------------------
bucketGap(b: MaturityBucket): number {
return b.inflow - b.outflow;
}
bucketGapPct(b: MaturityBucket): number {
return b.outflow === 0 ? 0 : +(((b.inflow - b.outflow) / b.outflow) * 100).toFixed(2);
}
cumulativeOutflow(idx: number): number {
return this.buckets.slice(0, idx + 1).reduce((s, b) => s + b.outflow, 0);
}
cumulativeInflow(idx: number): number {
return this.buckets.slice(0, idx + 1).reduce((s, b) => s + b.inflow, 0);
}
cumulativeGap(idx: number): number {
return this.cumulativeInflow(idx) - this.cumulativeOutflow(idx);
}
cumulativeGapPct(idx: number): number {
const out = this.cumulativeOutflow(idx);
return out === 0 ? 0 : +((this.cumulativeGap(idx) / out) * 100).toFixed(2);
}
/** Columns the core-system extract does not map. Named on screen, not quietly filled in. */
readonly unmappedColumns = 'Investments, Advances, Bank-FD balances and Other Assets';
get totalOutflow(): number {
return this.buckets.reduce((s, b) => s + b.outflow, 0);
}
get totalInflow(): number {
return this.buckets.reduce((s, b) => s + b.inflow, 0);
}
get totalGap(): number {
return this.totalInflow - this.totalOutflow;
}
get totalGapPct(): number {
return this.totalOutflow === 0 ? 0 : +((this.totalGap / this.totalOutflow) * 100).toFixed(2);
}
/** RBI tolerance test: cumulative negative gap up to bucket 3 (14 days) ≤ 5% of cumulative outflow */
get gap14dPct(): number {
return this.cumulativeGapPct(2);
}
/** Up to bucket 4 (30 days) ≤ 10% */
get gap28dPct(): number {
return this.cumulativeGapPct(3);
}
get gap14dWithinTolerance(): boolean {
return this.gap14dPct >= -this.bank.tolerance14d;
}
get gap28dWithinTolerance(): boolean {
return this.gap28dPct >= -this.bank.tolerance28d;
}
bucketStatus(b: MaturityBucket): 'surplus' | 'deficit' | 'balanced' {
const gap = this.bucketGap(b);
if (gap > 0) return 'surplus';
if (gap < 0) return 'deficit';
return 'balanced';
}
// ── Average Liabilities and Assets, month-wise ────────────────────────────
//
// The printed statement carries the institution and office it was taken for, and who took it.
// Those come off the session rather than being typed into the report, so a branch login prints
// its own office and its own user code instead of whatever the last person hard-coded.
/** Stamped when the statement is run, and again whenever the parameters change it. */
generatedAt = this.formatPrintDate(new Date());
/** "<BANK> , <OFFICE>" — the banner line the printed statement opens with. */
get statementHeading(): string {
return `${this.bank.name.toUpperCase()} , ${this.branchScope.officeName.toUpperCase()}`;
}
/** Office the statement was taken for, off the session rather than typed into the report. */
get branchName(): string {
return this.branchScope.officeName;
}
/** Who ran the report — the login's username, which is what the printed footer calls a code. */
get statementUserCode(): string {
return (this.auth.getCredentials()?.username || this.bank.userCode).toUpperCase();
}
/** Stamped when the tab is opened, so the footer records when the figures were taken off. */
readonly statementPrintedAt = this.formatPrintDate(new Date());
/** Month choices for the "up to" parameter, e.g. 'DEC-2025'. */
get monthOptions(): { index: number; label: string }[] {
return this.monthly.map((m, index) => ({ index, label: m.month }));
}
/**
* Month rows, then the Total and Average lines the statement closes with.
*
* Average is the mean of the months shown, not Total ÷ 12 — a statement printed part-way
* through the year would otherwise divide by months that have not happened yet.
*/
get avgBalanceRows(): AvgBalanceRow[] {
return this.monthly.map((m) => {
/** The months the statement prints. */
get statementRows(): StatementRow[] {
return this.monthly.slice(0, this.upToIndex + 1).map((m) => {
const [
name,
year
@@ -412,7 +332,7 @@ export class AlmReportDialogComponent {
});
}
private readonly avgBalanceColumns: (keyof AvgBalanceRow)[] = [
private readonly amountColumns: (keyof StatementRow)[] = [
'capital',
'reserveProv',
'deposits',
@@ -426,190 +346,92 @@ export class AlmReportDialogComponent {
'otherAssets'
];
get avgBalanceTotal(): AvgBalanceRow {
const rows = this.avgBalanceRows;
const out = { label: 'Total', sub: '' } as AvgBalanceRow;
for (const key of this.avgBalanceColumns) {
out[key] = rows.reduce((s, r) => s + (r[key] as number), 0) as never;
get totalRow(): StatementRow {
const rows = this.statementRows;
const out = { label: 'Total', sub: '' } as StatementRow;
for (const key of this.amountColumns) {
out[key] = +rows.reduce((s, r) => s + (r[key] as number), 0).toFixed(2) as never;
}
return out;
}
get avgBalanceAverage(): AvgBalanceRow {
const rows = this.avgBalanceRows;
const total = this.avgBalanceTotal;
const out = { label: 'Average', sub: '' } as AvgBalanceRow;
for (const key of this.avgBalanceColumns) {
/**
* Mean of the months printed, not Total ÷ 12 — a statement that stops at December must divide by
* nine, or every average on it is understated.
*/
get averageRow(): StatementRow {
const rows = this.statementRows;
const total = this.totalRow;
const out = { label: 'Average', sub: '' } as StatementRow;
for (const key of this.amountColumns) {
out[key] = (rows.length ? +((total[key] as number) / rows.length).toFixed(2) : 0) as never;
}
return out;
}
/** dd/MM/yyyy hh:mm:ssAM — the format the printed statement stamps its footer with. */
private formatPrintDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
const hours24 = d.getHours();
const meridiem = hours24 < 12 ? 'AM' : 'PM';
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
return (
`${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ` +
`${pad(hours12)}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${meridiem}`
);
/** Re-running the statement re-stamps the footer, since the figures on it have changed. */
runReport(): void {
this.generatedAt = this.formatPrintDate(new Date());
}
/** Monthly totals helpers */
monthTotalLiab(m: MonthSnapshot): number {
return m.reserveProv + m.borrowings + m.fds;
}
monthTotalAssets(m: MonthSnapshot): number {
return m.cashOnHand + m.investments + m.otherAssets;
}
/** Average across months */
avg<K extends keyof MonthSnapshot>(key: K): number {
if (typeof this.monthly[0][key] !== 'number') return 0;
const total = this.monthly.reduce((s, m) => s + (m[key] as number), 0);
return +(total / this.monthly.length).toFixed(2);
}
// -------------------- Filter --------------------
get filteredBuckets(): MaturityBucket[] {
if (!this.filterBucket) return this.buckets;
if (this.filterBucket === 'short') return this.buckets.slice(0, 4);
if (this.filterBucket === 'medium') return this.buckets.slice(4, 8);
if (this.filterBucket === 'long') return this.buckets.slice(8);
return this.buckets;
}
// -------------------- Excel Exports --------------------
/** Form ALM-1 — Statement of Structural Liquidity */
exportFormAlm(): void {
const rows: string[][] = [];
rows.push([
this.bank.name,
this.bank.branch
]);
rows.push([
`STATEMENT OF STRUCTURAL LIQUIDITY (${this.bank.formCode})`,
`As On ${this.bank.asOnDate}`
]);
rows.push([
`RBI Circular: ${this.bank.rbiCircular}`,
`Quarter: ${this.bank.quarter}`
]);
rows.push([
`Report Ref: ${this.bank.reportRefNo}`,
`User Code: ${this.bank.userCode}`
]);
rows.push([]);
rows.push([
'NDTL (₹ Lakhs)',
this.bank.ndtl.toString()
]);
rows.push([]);
rows.push(['Maturity Ladder (₹ in Lakhs)']);
rows.push([
'Bucket',
'Time Period',
'Outflows (Liabilities)',
'Inflows (Assets)',
'Mismatch (Gap)',
'Cum. Outflows',
'Cum. Inflows',
'Cum. Gap',
'Cum. Gap % of Outflow'
]);
this.buckets.forEach((b, i) => {
rows.push([
b.code,
b.label,
b.outflow.toFixed(2),
b.inflow.toFixed(2),
this.bucketGap(b).toFixed(2),
this.cumulativeOutflow(i).toFixed(2),
this.cumulativeInflow(i).toFixed(2),
this.cumulativeGap(i).toFixed(2),
this.cumulativeGapPct(i).toFixed(2) + '%'
]);
});
rows.push([]);
rows.push([
'TOTAL',
'',
this.totalOutflow.toFixed(2),
this.totalInflow.toFixed(2),
this.totalGap.toFixed(2)
]);
rows.push([]);
rows.push(['RBI Tolerance Compliance']);
rows.push([
'Cumulative Gap up to 14 days',
this.gap14dPct.toFixed(2) + '%',
`Limit: -${this.bank.tolerance14d}%`,
this.gap14dWithinTolerance ? 'WITHIN' : 'BREACH'
]);
rows.push([
'Cumulative Gap up to 30 days',
this.gap28dPct.toFixed(2) + '%',
`Limit: -${this.bank.tolerance28d}%`,
this.gap28dWithinTolerance ? 'WITHIN' : 'BREACH'
]);
rows.push([]);
rows.push([
'Certified by',
this.bank.principalOfficer + ' — Principal Officer'
]);
this.saveTsv(rows, `ALM-StructuralLiquidity-${this.dateTag()}.xls`);
}
/** Monthly snapshot export */
exportMonthlyXls(): void {
const rows: string[][] = [];
rows.push([
`${this.bank.name} — ${this.bank.branch}`,
`Monthly ALM Snapshot — FY ${this.bank.fiscalYear}`
]);
rows.push([]);
rows.push([
'Month',
'Reserve/Prov (Excl. OIR)',
'Borrowings',
'FDs',
'Total Liabilities',
'Cash on Hand',
'Investments incl. Bal/Repo',
'Other Assets (Excl. IRA)',
'Total Assets'
]);
for (const m of this.monthly) {
rows.push([
m.month,
m.reserveProv.toFixed(2),
m.borrowings.toFixed(2),
m.fds.toFixed(2),
this.monthTotalLiab(m).toFixed(2),
m.cashOnHand.toFixed(2),
m.investments.toFixed(2),
m.otherAssets.toFixed(2),
this.monthTotalAssets(m).toFixed(2)
]);
/**
* Prints the statement on its own.
*
* The dialog sits in an overlay on top of the application, so `window.print()` would put the whole
* screen behind it on the paper. The rendered statement is handed to a window of its own instead,
* which needs no application-wide print stylesheet and cannot pick up anything else on the page.
*/
print(): void {
const node = this.statementRef?.nativeElement;
if (!node) {
return;
}
rows.push([
'AVERAGE',
this.avg('reserveProv').toFixed(2),
this.avg('borrowings').toFixed(2),
this.avg('fds').toFixed(2),
(this.avg('reserveProv') + this.avg('borrowings') + this.avg('fds')).toFixed(2),
this.avg('cashOnHand').toFixed(2),
this.avg('investments').toFixed(2),
this.avg('otherAssets').toFixed(2),
(this.avg('cashOnHand') + this.avg('investments') + this.avg('otherAssets')).toFixed(2)
]);
this.saveTsv(rows, `ALM-Monthly-${this.dateTag()}.xls`);
const win = window.open('', '_blank', 'width=1100,height=760');
if (!win) {
// Blocked by the browser — fall back to the page print, which the print stylesheet handles.
window.print();
return;
}
win.document.write(
`<!doctype html><html><head><title>Average Liabilities and Assets — ${this.bank.financialYear}</title>` +
`<style>${AlmReportDialogComponent.PRINT_CSS}</style></head><body>${node.outerHTML}</body></html>`
);
win.document.close();
win.focus();
// Let the document lay out before the print dialog measures it.
win.setTimeout(() => {
win.print();
win.close();
}, 150);
}
/** Average Liabilities and Assets — the printed statement, column for column. */
exportAvgBalancesXls(): void {
/** Stylesheet for the print window — the statement as the core system lays it out on paper. */
private static readonly PRINT_CSS = `
@page { size: A4 landscape; margin: 12mm; }
* { box-sizing: border-box; }
body { margin: 0; font-family: Arial, Helvetica, sans-serif; color: #000; }
.stmt { padding: 0; }
.stmt-bank { font-size: 8.5pt; font-weight: 700; }
.stmt-title { margin: 2pt 0 14pt; font-size: 7.5pt; }
.stmt-tbl { width: 100%; border-collapse: collapse; font-size: 8pt; }
.stmt-tbl th { padding: 0 6pt 7pt; text-align: right; vertical-align: bottom;
font-weight: 400; font-size: 7pt; line-height: 1.3; white-space: nowrap; }
.stmt-tbl td { padding: 5pt 6pt; text-align: right; font-weight: 700; white-space: nowrap; }
.stmt-tbl .col-month { text-align: left; }
.stmt-tbl .col-grp { text-align: center; padding-bottom: 2pt; }
.stmt-tbl tr { page-break-inside: avoid; }
.stmt-sum td { padding-top: 10pt; }
.stmt-sum:first-of-type td { border-top: 0.5pt solid #000; }
.stmt-sum .col-month { font-weight: 400; }
.stmt-foot { margin-top: 24pt; font-size: 7pt; line-height: 1.8; }
`;
close(): void {
this.dialogRef.close();
}
/** Excel copy of the statement, column for column and in the same order. */
exportXls(): void {
const rows: string[][] = [];
rows.push([this.statementHeading]);
rows.push(['Average Liabilities and Assets Month-wise during the year']);
@@ -628,7 +450,7 @@ export class AlmReportDialogComponent {
'Advances',
'Other Assets Exclude IRA'
]);
const line = (r: AvgBalanceRow): string[] => [
const line = (r: StatementRow): string[] => [
`${r.label} ${r.sub}`.trim(),
r.capital.toFixed(2),
r.reserveProv.toFixed(2),
@@ -642,71 +464,38 @@ export class AlmReportDialogComponent {
r.advances.toFixed(2),
r.otherAssets.toFixed(2)
];
this.avgBalanceRows.forEach((r) => rows.push(line(r)));
rows.push(line(this.avgBalanceTotal));
rows.push(line(this.avgBalanceAverage));
this.statementRows.forEach((r) => rows.push(line(r)));
rows.push(line(this.totalRow));
rows.push(line(this.averageRow));
rows.push([]);
rows.push([`Print Date : ${this.statementPrintedAt}`]);
rows.push([`Not mapped in this extract: ${this.unmappedColumns}`]);
rows.push([`Print Date : ${this.generatedAt}`]);
rows.push([`Report Ref No : ${this.bank.reportRefNo} User Code:${this.statementUserCode}`]);
this.saveTsv(rows, `ALM-AverageLiabilitiesAssets-${this.dateTag()}.xls`);
const tsv = rows.map((r) => r.map((v) => (v || '').replace(/\t/g, ' ')).join('\t')).join('\n');
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ALM-AverageLiabilitiesAssets-${this.dateTag()}.xls`;
a.click();
URL.revokeObjectURL(url);
}
/** Maturity ladder CSV */
exportLadderCsv(): void {
const headers = [
'Bucket',
'Time Period',
'Outflow (₹ Lakh)',
'Inflow (₹ Lakh)',
'Gap',
'Gap %'
];
const rows = [headers];
for (const b of this.buckets) {
rows.push([
b.code,
b.label,
b.outflow.toString(),
b.inflow.toString(),
this.bucketGap(b).toString(),
this.bucketGapPct(b).toFixed(2) + '%'
]);
}
this.saveCsv(rows, `ALM-Ladder-${this.dateTag()}.csv`);
}
setTab(tab: 'dashboard' | 'ladder' | 'monthly' | 'avgbal' | 'returns'): void {
this.activeTab = tab;
}
close(): void {
this.dialogRef.close();
}
// ---- helpers ----
private dateTag(): string {
const d = new Date();
return `${String(d.getDate()).padStart(2, '0')}${String(d.getMonth() + 1).padStart(2, '0')}${d.getFullYear()}`;
}
private saveTsv(rows: string[][], filename: string): void {
const tsv = rows.map((r) => r.map((v) => (v || '').replace(/\t/g, ' ')).join('\t')).join('\n');
this.saveBlob(tsv, filename, 'application/vnd.ms-excel');
}
private saveCsv(rows: string[][], filename: string): void {
const escape = (v: string): string => {
const s = (v || '').replace(/"/g, '""');
if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s}"`;
return s;
};
const csv = rows.map((r) => r.map(escape).join(',')).join('\n') + '\n';
this.saveBlob(csv, filename, 'text/csv');
}
private saveBlob(content: string, filename: string, mime: string): void {
const blob = new Blob([content], { type: `${mime};charset=utf-8` });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
/** dd/MM/yyyy hh:mm:ssAM — the format the printed statement stamps its footer with. */
private formatPrintDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
const hours24 = d.getHours();
const meridiem = hours24 < 12 ? 'AM' : 'PM';
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
return (
`${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ` +
`${pad(hours12)}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${meridiem}`
);
}
}