From 5581d4180df7d9f925bfce71c04d22e1a7458166 Mon Sep 17 00:00:00 2001 From: keshav Date: Thu, 13 Aug 2026 15:05:16 +0530 Subject: [PATCH] feat(clients, alm): add CIN/MCA verification & authorized signatories tab; refactor ALM report print and export --- .../client-general-step.component.html | 115 ++- .../client-general-step.component.scss | 97 ++ .../client-general-step.component.ts | 203 +++- .../client-kyc-step.component.html | 453 +++++---- .../client-kyc-step.component.scss | 48 + .../client-kyc-step.component.ts | 412 ++++++-- .../client-preview-step.component.html | 12 +- src/app/clients/clients-routing.module.ts | 10 + .../authorized-signatories-tab.component.html | 95 ++ .../authorized-signatories-tab.component.scss | 206 ++++ .../authorized-signatories-tab.component.ts | 137 +++ .../clients-view/clients-view.component.html | 16 + .../upload-image-dialog.component.html | 14 +- .../upload-image-dialog.component.scss | 42 + .../upload-image-dialog.component.ts | 33 + .../kyc-tab/kyc-tab.component.html | 791 ++++++++-------- .../clients-view/kyc-tab/kyc-tab.component.ts | 11 + .../personal-data-tab.component.html | 8 + src/app/clients/clients.service.ts | 9 + .../create-client/create-client.component.ts | 11 +- .../edit-client/edit-client.component.html | 7 + .../edit-client/edit-client.component.ts | 47 +- .../shared/entity-kyc-documents.spec.ts | 88 ++ .../clients/shared/entity-kyc-documents.ts | 138 +++ src/app/clients/utils/name-case.util.spec.ts | 55 ++ src/app/clients/utils/name-case.util.ts | 64 ++ .../alm-report-dialog.component.html | 651 +++---------- .../alm-report-dialog.component.scss | 891 ++++-------------- .../alm-report-dialog.component.ts | 791 ++++++---------- 29 files changed, 3002 insertions(+), 2453 deletions(-) create mode 100644 src/app/clients/clients-view/authorized-signatories-tab/authorized-signatories-tab.component.html create mode 100644 src/app/clients/clients-view/authorized-signatories-tab/authorized-signatories-tab.component.scss create mode 100644 src/app/clients/clients-view/authorized-signatories-tab/authorized-signatories-tab.component.ts create mode 100644 src/app/clients/shared/entity-kyc-documents.spec.ts create mode 100644 src/app/clients/shared/entity-kyc-documents.ts create mode 100644 src/app/clients/utils/name-case.util.spec.ts create mode 100644 src/app/clients/utils/name-case.util.ts diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html index 08a6f88dc..463d99599 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html @@ -136,7 +136,14 @@ | translate }} - + @if (createClientForm.controls.fullname.hasError('required')) { Client name is required } @else if (createClientForm.controls.fullname.hasError('pattern')) { @@ -151,7 +158,9 @@
Entity Name: - {{ createClientForm.get('fullname')?.value }} + {{ + createClientForm.get('fullname')?.value + }}
@if (!sanctionsVerified && !sanctionsBlocked) { @@ -214,7 +223,14 @@ {{ 'labels.inputs.First Name' | translate }} badge - + @if (createClientForm.controls.firstname.hasError('required')) { First name is required } @else if (createClientForm.controls.firstname.hasError('pattern')) { @@ -228,7 +244,13 @@ {{ 'labels.inputs.Middle Name' | translate }} person_outline - + @if ( createClientForm.controls.middlename?.hasError('pattern') && createClientForm.controls.middlename?.touched @@ -241,7 +263,14 @@ {{ 'labels.inputs.Last Name' | translate }} person - + @if (createClientForm.controls.lastname.hasError('required')) { Last name is required } @else if (createClientForm.controls.lastname.hasError('pattern')) { @@ -694,6 +723,9 @@ } + +

@@ -1148,6 +1180,79 @@

} + + @if (createClientForm.get('legalFormId')?.value === LegalFormId.ENTITY) { +
+

+ + Turnover & Risk Profile +

+
+ + Monthly Turnover + ₹ + + Yearly turnover fills in automatically (× 12) + @if ( + createClientForm.get('monthlyTurnover')?.hasError('pattern') && + createClientForm.get('monthlyTurnover')?.touched + ) { + Enter a valid amount + } @else if ( + createClientForm.get('monthlyTurnover')?.hasError('min') && createClientForm.get('monthlyTurnover')?.touched + ) { + Amount cannot be negative + } + + + + Annual Turnover + ₹ + + Drives the risk grade below + @if ( + createClientForm.get('annualTurnover')?.hasError('pattern') && + createClientForm.get('annualTurnover')?.touched + ) { + Enter a valid amount + } @else if ( + createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched + ) { + Amount cannot be negative + } + + +
+
+ + Auto-calculated Risk Category + {{ riskCategoryLabel(entityTurnoverRisk.category) }} +
+

{{ entityTurnoverRisk.reason }}

+

+ Up to ₹1 crore — Low  ·  ₹1 crore to ₹10 crore — Medium  ·  Above ₹10 crore — High +

+

+ Carried into the KYC tab, where it can still be overridden with a recorded reason. +

+
+
+
+ } + @if (createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss b/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss index b2ce73059..7d253f0a8 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss @@ -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; diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index 43fa5fedd..3bd5bc706 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -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, diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html index aaf152d4d..f139711f3 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html @@ -588,95 +588,23 @@ @if (isNonIndividual) { - - - Incorporation Date - calendar_today - - - + + + Date of Incorporation + event_available + + + + As per Certificate of Incorporation / Registration - -
- - GSTIN Number - receipt_long - - @if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) { - Invalid GSTIN format (e.g. 22AAAAA0000A1Z5) - } - @if (gstinVerifyStatus === 'VERIFIED') { - check_circle - } - @if (gstinVerifyStatus === 'FAILED') { - cancel - } - -
- @if (gstinVerifyStatus === 'NONE' || gstinVerifyStatus === 'FAILED') { - - } - @if (gstinVerifyStatus === 'VERIFIED') { - Verified - } -
-
- @if (gstinVerifyStatus === 'VERIFIED') { -
-
- - GSTIN Verified Successfully -
-
- @if (gstinVerifyName) { -
- Trade Name / Legal Name - {{ gstinVerifyName }} -
- } - @if (gstinVerifyState) { -
- State - {{ gstinVerifyState }} -
- } -
- Verified On - {{ gstinVerifiedOn }} -
-
-
- } - @if (constitutionMeta?.value === 'PROPRIETORSHIP') { @@ -826,8 +754,88 @@ } } - - @if (constitutionMeta?.showCin) { + +
+ + GSTIN Number + receipt_long + + @if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) { + Invalid GSTIN format (e.g. 22AAAAA0000A1Z5) + } + @if (gstinVerifyStatus === 'VERIFIED') { + check_circle + } + @if (gstinVerifyStatus === 'FAILED') { + cancel + } + +
+ @if (gstinVerifyStatus === 'NONE' || gstinVerifyStatus === 'FAILED') { + + } + @if (gstinVerifyStatus === 'VERIFIED') { + Verified + } +
+
+ @if (gstinVerifyStatus === 'VERIFIED') { +
+
+ + GSTIN Verified Successfully +
+
+ @if (gstinVerifyName) { +
+ Trade Name / Legal Name + {{ gstinVerifyName }} +
+ } + @if (gstinVerifyState) { +
+ State + {{ gstinVerifyState }} +
+ } +
+ Verified On + {{ gstinVerifiedOn }} +
+
+
+ } + + + @if (isNonIndividual) {
CIN (Corporate Identity Number) @@ -850,7 +858,7 @@ }
- @if (cinVerifyStatus === 'NONE' || cinVerifyStatus === 'FAILED') { + @if (cinVerifyStatus !== 'VERIFIED') { } @if (cinVerifyStatus === 'VERIFIED') { Verified } + @if (cinVerifyStatus === 'UNCONFIRMED') { + Saved — not confirmed + }
+ + @if (cinUnconfirmedMessage) { +
+ + {{ cinUnconfirmedMessage }} +
+ } + @if (cinVerifying) {
@@ -1429,15 +1452,19 @@
} + @if (kycForm.get('allowRiskOverride')?.value || isKycVerified) { {{ - 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)' }} - @if ((isKycVerified || kycForm.get('allowRiskOverride')?.value) && !kycForm.get('remarks')?.value?.trim()) { - - Remarks are mandatory when - {{ isKycVerified ? 'updating a verified KYC record' : 'overriding the risk category' }} - + @if (kycForm.get('allowRiskOverride')?.value && !kycForm.get('remarks')?.value?.trim()) { + Remarks are mandatory when overriding the risk category } } @@ -1672,77 +1694,92 @@
-
-

- - Form 97 / 98 + @if (!isNonIndividual) { +
+

+ + Form 97 / 98 + @if (!kycForm.get('hasPan')?.value) { + Required — No PAN + } +

@if (!kycForm.get('hasPan')?.value) { - Required — No PAN - } -

- @if (!kycForm.get('hasPan')?.value) { -
- warning - Client does not have PAN. Form 97/98 is mandatory. -
-
- - Form Type - - Form 97 - Form 98 - - - - Declaration Date - - - - -
- } @else { -

Client has PAN — Form 97/98 not required.

- } -
- - -
-

- - Form 121 -

-
-
- Form 121 Applicable -
- @if (kycForm.get('form15Applicable')?.value) { - - Form Type - - Form 121 (Below 60 years) - Form 121 (Senior Citizen 60+) - - - - Financial Year - - +
+ warning + Client does not have PAN. Form 97/98 is mandatory. +
+
+ + Form Type + + Form 97 + Form 98 + + + + Declaration Date + + + + +
+ } @else { +

Client has PAN — Form 97/98 not required.

}
-
+ } - + + @if (!isNonIndividual) { +
+

+ + Form 121 +

+
+
+ Form 121 Applicable +
+ @if (kycForm.get('form15Applicable')?.value) { + + Form Type + + Form 121 (Below 60 years) + Form 121 (Senior Citizen 60+) + + + + Financial Year + + + } +
+
+ } + +

- Photograph & Signature - Required + {{ isNonIndividual ? 'Entity Photograph' : 'Photograph & Signature' }} + @if (isNonIndividual) { + Optional + } @else { + Required + }

-
+
@if (photographPreview) {
@@ -1758,12 +1795,16 @@
} @else { -
+
- Client Photograph - * + {{ isNonIndividual ? 'Entity Photograph / Logo' : 'Client Photograph' }} + @if (!isNonIndividual) { + * + } + + + {{ isNonIndividual ? 'Office premises, signboard or logo — JPG / PNG, max 2MB' : 'JPG / PNG, max 2MB' }} - JPG / PNG, max 2MB
- @if (photoSigTouched) { + @if (photoSigTouched && !isNonIndividual) { Photograph is required } }
-
- @if (signaturePreview) { -
- Signature -
-
+
+ }
diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss index a9555ea7e..c2ac3c67c 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss @@ -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 { diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts index 29d2f22f2..e2ed6cfb1 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts @@ -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 = { 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 diff --git a/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html b/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html index 00857b355..564f7f25b 100644 --- a/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html +++ b/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html @@ -442,18 +442,18 @@ {{ kycData.enterprisePan }}
} + @if (kycData.incorporationDate) { +
+ Date of Incorporation + {{ kycData.incorporationDate }} +
+ } @if (kycData.cinNumber) {
CIN (Corporate Identity Number) {{ kycData.cinNumber }}
} - @if (kycData.incorporationDate) { -
- Incorporation Date - {{ kycData.incorporationDate | dateFormat }} -
- } @if (kycData.gstinNumber) {
GSTIN Number diff --git a/src/app/clients/clients-routing.module.ts b/src/app/clients/clients-routing.module.ts index d1d486fe8..22ee81703 100644 --- a/src/app/clients/clients-routing.module.ts +++ b/src/app/clients/clients-routing.module.ts @@ -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, diff --git a/src/app/clients/clients-view/authorized-signatories-tab/authorized-signatories-tab.component.html b/src/app/clients/clients-view/authorized-signatories-tab/authorized-signatories-tab.component.html new file mode 100644 index 000000000..59f3fb629 --- /dev/null +++ b/src/app/clients/clients-view/authorized-signatories-tab/authorized-signatories-tab.component.html @@ -0,0 +1,95 @@ + +
+ @if (loading) { +
+ +

Loading authorized signatories...

+
+ } + + @if (!loading) { +
+

Authorized Signatories

+ @if (signatories.length) { + {{ signatories.length }} on record + } +
+ + + @if (!isNonIndividual) { +
+ person + Authorized signatories apply to non-individual clients only. +
+ } @else if (!signatories.length) { +
+ group_off + No authorized signatories recorded for this entity. +
+ } @else { +

+ The persons authorised to transact on this entity's behalf, as named in its Board Resolution / Authorization + Letter. +

+ + @for (signatory of signatories; track $index) { +
+
+
person
+
+ {{ signatory.name || 'Unnamed signatory' }} + + @if (signatory.loading) { + Loading details… + } @else { + @if (signatory.cifNumber) { + CIF {{ signatory.cifNumber }} + } + @if (signatory.officeName) { + •{{ signatory.officeName }} + } + @if (signatory.status) { + •{{ signatory.status }} + } + + @if (!signatory.clientId) { + Name on record only — no linked client + } + } + +
+
+ +
+
+ DIN + @if (signatory.dinNumber) { + {{ signatory.dinNumber }} + @if (signatory.dinVerified) { + check_circle Verified + } @else { + radio_button_unchecked Unverified + } + } @else { + — + } +
+ + @if (signatory.clientId) { + + open_in_new View client + + } +
+
+ } + } + } +
diff --git a/src/app/clients/clients-view/authorized-signatories-tab/authorized-signatories-tab.component.scss b/src/app/clients/clients-view/authorized-signatories-tab/authorized-signatories-tab.component.scss new file mode 100644 index 000000000..35758613a --- /dev/null +++ b/src/app/clients/clients-view/authorized-signatories-tab/authorized-signatories-tab.component.scss @@ -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; + } +} diff --git a/src/app/clients/clients-view/authorized-signatories-tab/authorized-signatories-tab.component.ts b/src/app/clients/clients-view/authorized-signatories-tab/authorized-signatories-tab.component.ts new file mode 100644 index 000000000..e4e80dc2b --- /dev/null +++ b/src/app/clients/clients-view/authorized-signatories-tab/authorized-signatories-tab.component.ts @@ -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 , and the shared +// imports only carry the - diff --git a/src/app/clients/clients-view/custom-dialogs/upload-image-dialog/upload-image-dialog.component.scss b/src/app/clients/clients-view/custom-dialogs/upload-image-dialog/upload-image-dialog.component.scss index af9f4c075..b7e90cd61 100644 --- a/src/app/clients/clients-view/custom-dialogs/upload-image-dialog/upload-image-dialog.component.scss +++ b/src/app/clients/clients-view/custom-dialogs/upload-image-dialog/upload-image-dialog.component.scss @@ -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; +} diff --git a/src/app/clients/clients-view/custom-dialogs/upload-image-dialog/upload-image-dialog.component.ts b/src/app/clients/clients-view/custom-dialogs/upload-image-dialog/upload-image-dialog.component.ts index 75b8e769d..f11265f9f 100644 --- a/src/app/clients/clients-view/custom-dialogs/upload-image-dialog/upload-image-dialog.component.ts +++ b/src/app/clients/clients-view/custom-dialogs/upload-image-dialog/upload-image-dialog.component.ts @@ -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 . + 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); + } } diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html index d379b4baf..c91dfc132 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html @@ -104,221 +104,230 @@ Action
- -
- - credit_card PAN - - {{ kycData.panNumber || '—' }} - - @if (kycData.panVerified) { - check_circle Verified - } @else if (panDeclarationTaken) { - - assignment_turned_in Declaration - } @else { - radio_button_unchecked Pending - } - - {{ - kycData.panVerified - ? kycData.panVerifiedOn - ? formatDate(kycData.panVerifiedOn) - : '—' - : panDeclarationTaken && kycData.panDeclarationTakenOn - ? formatDate(kycData.panDeclarationTakenOn) - : '—' - }} - - - - @if (!kycData.panVerified && !panDeclarationTaken && kycData.panNumber) { - - } - -
+ @if (!kycData.panVerified && !panDeclarationTaken && kycData.panNumber) { + + } + +
- - @if (!kycData.panVerified && panDeclarationTaken) { -
-
- assignment_turned_in - PAN Declaration - @if (!saving) { - - - - - } -
-
-
- Does not match - {{ panDeclarationMismatchLabel }} + @if (!kycData.panVerified && panDeclarationTaken) { +
+
+ assignment_turned_in + PAN Declaration + @if (!saving) { + + + + + }
- @if (kycData.panDeclarationNameOnPan) { +
- Name on PAN - {{ kycData.panDeclarationNameOnPan }} + Does not match + {{ panDeclarationMismatchLabel }}
- } - @if (kycData.panDeclarationDobOnPan) { -
- DOB on PAN - {{ formatDate(kycData.panDeclarationDobOnPan) }} -
- } - @if (kycData.panDeclarationRemarks) { -
- Declaration file - {{ kycData.panDeclarationRemarks }} -
- } + @if (kycData.panDeclarationNameOnPan) { +
+ Name on PAN + {{ kycData.panDeclarationNameOnPan }} +
+ } + @if (kycData.panDeclarationDobOnPan) { +
+ DOB on PAN + {{ formatDate(kycData.panDeclarationDobOnPan) }} +
+ } + @if (kycData.panDeclarationRemarks) { +
+ Declaration file + {{ kycData.panDeclarationRemarks }} +
+ } +
+
+ PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file. +
-
- PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file. -
-
- } + } - -
- - fingerprint Aadhaar - - {{ kycData.aadhaarNumberMasked || '—' }} - - @if (kycData.aadhaarVerified) { - check_circle Verified - } @else if (aadhaarReceiptTaken) { - - receipt_long Receipt - } @else { - radio_button_unchecked Pending - } - - {{ - kycData.aadhaarVerified - ? kycData.aadhaarVerifiedOn - ? formatDate(kycData.aadhaarVerifiedOn) - : '—' - : aadhaarReceiptTaken && kycData.aadhaarReceiptTakenOn - ? formatDate(kycData.aadhaarReceiptTakenOn) - : '—' - }} - - @if (!showAadhaarReVerify && !aadhaarOtpSent) { - - } - +
+ + fingerprint Aadhaar + + {{ kycData.aadhaarNumberMasked || '—' }} + + @if (kycData.aadhaarVerified) { + check_circle Verified + } @else if (aadhaarReceiptTaken) { + + receipt_long Receipt + } @else { + radio_button_unchecked Pending + } + + {{ + kycData.aadhaarVerified + ? kycData.aadhaarVerifiedOn + ? formatDate(kycData.aadhaarVerifiedOn) + : '—' + : aadhaarReceiptTaken && kycData.aadhaarReceiptTakenOn + ? formatDate(kycData.aadhaarReceiptTakenOn) + : '—' + }} + + @if (!showAadhaarReVerify && !aadhaarOtpSent) { + + } + - @if (!kycData.aadhaarVerified && !aadhaarReceiptTaken) { - - } - -
+ @if (!kycData.aadhaarVerified && !aadhaarReceiptTaken) { + + } +
+
- - @if (!kycData.aadhaarVerified && aadhaarReceiptTaken) { -
-
- receipt_long - Aadhaar Enrolment Receipt - @if (!saving) { - - - - - } -
-
-
- Enrolment ID - {{ kycData.aadhaarEnrolmentId || '—' }} + @if (!kycData.aadhaarVerified && aadhaarReceiptTaken) { +
+
+ receipt_long + Aadhaar Enrolment Receipt + @if (!saving) { + + + + + }
- @if (kycData.aadhaarEnrolmentDate) { +
- Enrolment date - {{ formatDate(kycData.aadhaarEnrolmentDate) }} + Enrolment ID + {{ kycData.aadhaarEnrolmentId || '—' }}
- } - @if (kycData.aadhaarReceiptRemarks) { -
- Remarks - {{ kycData.aadhaarReceiptRemarks }} -
- } + @if (kycData.aadhaarEnrolmentDate) { +
+ Enrolment date + {{ formatDate(kycData.aadhaarEnrolmentDate) }} +
+ } + @if (kycData.aadhaarReceiptRemarks) { +
+ Remarks + {{ kycData.aadhaarReceiptRemarks }} +
+ } +
+
+ Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file. +
-
- Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file. -
-
+ } } - @if (isNonIndividual && kycData?.enterprisePan) { + + @if (isNonIndividual && enterprisePanNumber) {
credit_card Enterprise PAN - {{ kycData.enterprisePan }} + {{ enterprisePanNumber }} - @if (kycData.enterprisePanVerified) { + @if (kycData.panVerified) { check_circle Verified } @else { radio_button_unchecked Pending } {{ - kycData.enterprisePanVerifiedOn ? formatDate(kycData.enterprisePanVerifiedOn) : '—' + kycData.panVerified && kycData.panVerifiedOn ? formatDate(kycData.panVerifiedOn) : '—' }} + + + +
+ } + + + @if (isNonIndividual && kycData?.incorporationDate) { +
+ + event_available Date of Incorporation + + {{ formatDate(kycData.incorporationDate) }} + — + —
} @@ -464,68 +509,61 @@
} - - - @if (isNonIndividual && kycData?.incorporationDate) { - - }
-
-
-
- credit_card -
-
-
Verification Status
-
PAN ↔ Aadhaar
-
- @if (kycData?.panAadhaarSeedingStatus === 'Y') { - check_circle Linked (Seeded) - } @else if (kycData?.panAadhaarSeedingStatus === 'N') { - cancel Not Linked - } @else if (kycData?.panAadhaarSeedingStatus === 'E') { - info Exempt - } @else { - radio_button_unchecked 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) { +
+
+
+ credit_card +
+
+
Verification Status
+
PAN ↔ Aadhaar
+
+ @if (kycData?.panAadhaarSeedingStatus === 'Y') { + check_circle Linked (Seeded) + } @else if (kycData?.panAadhaarSeedingStatus === 'N') { + cancel Not Linked + } @else if (kycData?.panAadhaarSeedingStatus === 'E') { + info Exempt + } @else { + radio_button_unchecked Pending + } +
+
+ @if (kycData?.panAadhaarSeedingStatus === 'Y') { + check + } @else if (kycData?.panAadhaarSeedingStatus === 'N') { + close + } @else if (kycData?.panAadhaarSeedingStatus === 'E') { + info + } @else { + schedule + } +
+
As per Income Tax Portal — Section 139AA
-
- @if (kycData?.panAadhaarSeedingStatus === 'Y') { - check - } @else if (kycData?.panAadhaarSeedingStatus === 'N') { - close - } @else if (kycData?.panAadhaarSeedingStatus === 'E') { - info - } @else { - schedule - } -
-
As per Income Tax Portal — Section 139AA
-
+ } @if (showAadhaarReVerify && !aadhaarOtpSent) { @@ -795,15 +833,19 @@
- +
- {{ 'labels.inputs.PAN Number' | translate }} + {{ + isNonIndividual ? 'Enterprise PAN' : ('labels.inputs.PAN Number' | translate) + }} KYC
- {{ 'labels.inputs.PAN Number' | translate }} + {{ + isNonIndividual ? 'Enterprise PAN' : ('labels.inputs.PAN Number' | translate) + }} -
- Customer's PAN card genuinely carries different details, or PAN cannot be checked? - -
+ @if (!isNonIndividual) { +
+ Customer's PAN card genuinely carries different details, or PAN cannot be checked? + +
+ } } @if (verificationStatus.pan === 'VERIFIED' && verificationDetails.pan?.name) { {{ verificationDetails.pan.name }} }
- -
-
- {{ 'labels.inputs.Aadhaar Number' | translate }} - KYC -
-
- - {{ 'labels.inputs.Aadhaar Number' | translate }} - - @if (kycData?.aadhaarNumberMasked) { - On file: {{ kycData.aadhaarNumberMasked }} - } - @if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) { - {{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }} - } - @if (verificationStatus.aadhaar === 'VERIFIED') { - check_circle - } - @if (verificationStatus.aadhaar === 'FAILED') { - cancel - } - - @if (!aadhaarOtpSent) { - - } -
- - - @if (aadhaarOtpSent) { -
-

- sms - OTP sent to Aadhaar-linked mobile -

-
- - Enter OTP - - + @if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) { + {{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }} + } + @if (verificationStatus.aadhaar === 'VERIFIED') { + check_circle + } + @if (verificationStatus.aadhaar === 'FAILED') { + cancel + } + + @if (!aadhaarOtpSent) { - -
+ }
- } - @if (verificationError.aadhaar) { -
- error_outline - {{ verificationError.aadhaar }} -
- + @if (aadhaarOtpSent) { +
+

+ sms + OTP sent to Aadhaar-linked mobile +

+
+ + Enter OTP + + + + +
+
+ } + + @if (verificationError.aadhaar) { +
+ error_outline + {{ verificationError.aadhaar }} +
+ -
- Customer has applied for Aadhaar but not received it yet? - -
- } -
+
+ Customer has applied for Aadhaar but not received it yet? + +
+ } +
+ } @if (isNonIndividual) { diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts index baa19c44b..563f34450 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts @@ -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; diff --git a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html index 42dcb79ba..a5643242e 100644 --- a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html +++ b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html @@ -222,6 +222,14 @@
{{ clientViewData.clientNonPersonDetails.incorpNumber }}
} + @if (clientViewData.clientNonPersonDetails.dateOfRegistration) { +
+
{{ 'labels.inputs.Incorporation Date' | translate }}
+
+ {{ clientViewData.clientNonPersonDetails.dateOfRegistration | dateFormat: 'DD-MM-YYYY' }} +
+
+ } @if (clientViewData.clientNonPersonDetails.remarks) {
{{ 'labels.inputs.Remarks' | translate }}
diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index 01ac531af..b84bf8709 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -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 { + 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. diff --git a/src/app/clients/create-client/create-client.component.ts b/src/app/clients/create-client/create-client.component.ts index 573966949..8fcde0678 100644 --- a/src/app/clients/create-client/create-client.component.ts +++ b/src/app/clients/create-client/create-client.component.ts @@ -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 {}

- Please click Verify Client to screen the client name against the UN Security Council - Consolidated Sanctions List before proceeding. + Please click {{ data?.isEntity ? 'Verify Entity' : 'Verify Client' }} to screen the client + name against the UN Security Council Consolidated Sanctions List before proceeding.

gavel @@ -1269,4 +1270,6 @@ export class SaveConfirmDialogComponent {} MatDialogClose ] }) -export class VerifyClientAlertDialogComponent {} +export class VerifyClientAlertDialogComponent { + data = inject(MAT_DIALOG_DATA); +} diff --git a/src/app/clients/edit-client/edit-client.component.html b/src/app/clients/edit-client/edit-client.component.html index b32a5c3cd..efbc8c74a 100644 --- a/src/app/clients/edit-client/edit-client.component.html +++ b/src/app/clients/edit-client/edit-client.component.html @@ -215,11 +215,18 @@
}
+ { + // 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 || '', diff --git a/src/app/clients/shared/entity-kyc-documents.spec.ts b/src/app/clients/shared/entity-kyc-documents.spec.ts new file mode 100644 index 000000000..5e54147a6 --- /dev/null +++ b/src/app/clients/shared/entity-kyc-documents.spec.ts @@ -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' + ]); + }); +}); diff --git a/src/app/clients/shared/entity-kyc-documents.ts b/src/app/clients/shared/entity-kyc-documents.ts new file mode 100644 index 000000000..9a4ea13e4 --- /dev/null +++ b/src/app/clients/shared/entity-kyc-documents.ts @@ -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 = { + 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(); + 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 + ]; +} diff --git a/src/app/clients/utils/name-case.util.spec.ts b/src/app/clients/utils/name-case.util.spec.ts new file mode 100644 index 000000000..e58b929b0 --- /dev/null +++ b/src/app/clients/utils/name-case.util.spec.ts @@ -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(''); + }); +}); diff --git a/src/app/clients/utils/name-case.util.ts b/src/app/clients/utils/name-case.util.ts new file mode 100644 index 000000000..f127ff906 --- /dev/null +++ b/src/app/clients/utils/name-case.util.ts @@ -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(' '); +} diff --git a/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.html b/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.html index 50603de5b..c30b5f24e 100644 --- a/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.html +++ b/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.html @@ -7,556 +7,121 @@ -->
- -
+ +
-

Asset Liability Management — Structural Liquidity

+

Average Liabilities and Assets

- {{ bank.name }} · {{ bank.formCode }} · {{ bank.quarter }} · As on - {{ bank.asOnDate }} + {{ bank.name }} · {{ branchName }} · FY {{ bank.financialYear }} · Month-wise during the + year

-
- - 14d Tolerance - {{ gap14dPct.toFixed(2) }}% - / -{{ bank.tolerance14d }}% - - + +
+ + +
+ + + + + + + + + + +
+ +

+   {{ unmappedColumns }} are not mapped in the + core-system extract and print as 0.00. Total and Average cover the {{ statementRows.length }} month(s) shown. +

+ + +
+
+
{{ statementHeading }}
+
Average Liabilities and Assets Month-wise during the year
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MonthCapitalReserve/Prov
Exclude OIR
DepositsBorrowingsOther LiabilitiesCash on HandBank BalancesInvestments
including
AdvancesOther Assets
Exclude IRA
CurrentFDs
+ {{ r.label }}
{{ r.sub }} +
{{ r.capital | number: '1.2-2' }}{{ r.reserveProv | number: '1.2-2' }}{{ r.deposits | number: '1.2-2' }}{{ r.borrowings | number: '1.2-2' }}{{ r.otherLiabilities | number: '1.2-2' }}{{ r.cashOnHand | number: '1.2-2' }}{{ r.bankCurrent | number: '1.2-2' }}{{ r.bankFds | number: '1.2-2' }}{{ r.investments | number: '1.2-2' }}{{ r.advances | number: '1.2-2' }}{{ r.otherAssets | number: '1.2-2' }}
{{ r.label }}{{ r.capital | number: '1.2-2' }}{{ r.reserveProv | number: '1.2-2' }}{{ r.deposits | number: '1.2-2' }}{{ r.borrowings | number: '1.2-2' }}{{ r.otherLiabilities | number: '1.2-2' }}{{ r.cashOnHand | number: '1.2-2' }}{{ r.bankCurrent | number: '1.2-2' }}{{ r.bankFds | number: '1.2-2' }}{{ r.investments | number: '1.2-2' }}{{ r.advances | number: '1.2-2' }}{{ r.otherAssets | number: '1.2-2' }}
+
+ +
+
Print Date : {{ generatedAt }}
+
Report Ref No : {{ bank.reportRefNo }}  User Code:{{ statementUserCode }}
+
- - -
- - - - - -
- -
- - -
-
-
-
-
{{ totalGapPct.toFixed(1) }}%
-
Net Gap / Outflow
-
-
-
-
-
NDTL
-
₹ {{ bank.ndtl / 100 | number: '1.2-2' }} Cr
-
Net Demand & Time Liabilities
-
-
-
Total Outflows
-
₹ {{ totalOutflow / 100 | number: '1.2-2' }} Cr
-
All maturity buckets
-
-
-
Total Inflows
-
₹ {{ totalInflow / 100 | number: '1.2-2' }} Cr
-
- Net: {{ totalGap >= 0 ? '+' : '' }}{{ totalGap / 100 | number: '1.2-2' }} Cr -
-
-
- - -
-
-

RBI Tolerance Limits — Cumulative Gap

- RBI MD: {{ bank.rbiCircular }} -
-
-
-
-   Cumulative Gap up to 14 days - - {{ gap14dPct.toFixed(2) }}% - limit -{{ bank.tolerance14d }}% - {{ gap14dWithinTolerance ? 'WITHIN' : 'BREACH' }} - -
-
-
-
-
-
-
-
-   Cumulative Gap up to 30 days - - {{ gap28dPct.toFixed(2) }}% - limit -{{ bank.tolerance28d }}% - {{ gap28dWithinTolerance ? 'WITHIN' : 'BREACH' }} - -
-
-
-
-
-
-
-
- - -
-
-

Bucket-wise Mismatch Heatmap

-
-
-
-
{{ b.code }}
-
{{ bucketGapPct(b).toFixed(1) }}%
-
{{ b.label }}
-
-
-
- -
-
-

Reporting Snapshot

-
-
-
-
Reporting Bank
-
{{ bank.name }}
-
-
-
Branch
-
{{ bank.branch }}
-
-
-
Principal Officer
-
{{ bank.principalOfficer }}
-
-
-
Form Code
-
{{ bank.formCode }}
-
-
-
Report Ref No
-
{{ bank.reportRefNo }}
-
-
-
User Code
-
{{ bank.userCode }}
-
-
-
-
- - - -
-
-

Statement of Structural Liquidity — Maturity Ladder

-
- - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
BucketTime PeriodOutflow (Liab)Inflow (Assets)GapGap %Status
{{ b.code }}{{ b.label }}{{ b.outflow | number: '1.2-2' }}{{ b.inflow | number: '1.2-2' }} - {{ bucketGap(b) >= 0 ? '+' : '' }}{{ bucketGap(b) | number: '1.2-2' }} - - {{ bucketGapPct(b) >= 0 ? '+' : '' }}{{ bucketGapPct(b).toFixed(2) }}% - - - SURPLUS - DEFICIT - BALANCED - -
TOTAL - {{ totalOutflow | number: '1.2-2' }} - - {{ totalInflow | number: '1.2-2' }} - - {{ totalGap >= 0 ? '+' : '' }}{{ totalGap | number: '1.2-2' }} - - {{ totalGapPct >= 0 ? '+' : '' }}{{ totalGapPct.toFixed(2) }}% -
-
- - -

Cumulative Position

-
- - - - - - - - - - - - - - - - - - - -
Through BucketCum. OutflowCum. InflowCum. GapCum. Gap %
{{ b.code }} — {{ b.label }}{{ cumulativeOutflow(i) | number: '1.2-2' }}{{ cumulativeInflow(i) | number: '1.2-2' }} - {{ cumulativeGap(i) >= 0 ? '+' : '' }}{{ cumulativeGap(i) | number: '1.2-2' }} - - {{ cumulativeGapPct(i) >= 0 ? '+' : '' }}{{ cumulativeGapPct(i).toFixed(2) }}% -
-
-
-
- - - -
-
-

Monthly Asset / Liability Snapshot — FY {{ bank.fiscalYear }}

- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
MonthLiabilities (₹ Lakh)Assets (₹ Lakh)
Reserve/ProvBorrowingsFDsTotal LiabCashInvestmentsOther AssetsTotal Assets
{{ m.month }}{{ m.reserveProv | number: '1.2-2' }}{{ m.borrowings | number: '1.2-2' }}{{ m.fds | number: '1.2-2' }} - {{ monthTotalLiab(m) | number: '1.2-2' }} - {{ m.cashOnHand | number: '1.2-2' }}{{ m.investments | number: '1.2-2' }}{{ m.otherAssets | number: '1.2-2' }} - {{ monthTotalAssets(m) | number: '1.2-2' }} -
AVERAGE{{ avg('reserveProv') | number: '1.2-2' }}{{ avg('borrowings') | number: '1.2-2' }}{{ avg('fds') | number: '1.2-2' }} - {{ avg('reserveProv') + avg('borrowings') + avg('fds') | number: '1.2-2' }} - {{ avg('cashOnHand') | number: '1.2-2' }}{{ avg('investments') | number: '1.2-2' }}{{ avg('otherAssets') | number: '1.2-2' }} - {{ avg('cashOnHand') + avg('investments') + avg('otherAssets') | number: '1.2-2' }} -
-
-
-
- - - -
-
-

Average Liabilities and Assets Month-wise during the year

- -
- - -
-
{{ statementHeading }}
-
Average Liabilities and Assets Month-wise during the year
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
MonthCapitalReserve/Prov
Exclude OIR
DepositsBorrowingsOther LiabilitiesCash on HandBank BalancesInvestments
including
AdvancesOther Assets
Exclude IRA
CurrentFDs
- {{ r.label }}
{{ r.sub }} -
{{ r.capital | number: '1.2-2' }}{{ r.reserveProv | number: '1.2-2' }}{{ r.deposits | number: '1.2-2' }}{{ r.borrowings | number: '1.2-2' }}{{ r.otherLiabilities | number: '1.2-2' }}{{ r.cashOnHand | number: '1.2-2' }}{{ r.bankCurrent | number: '1.2-2' }}{{ r.bankFds | number: '1.2-2' }}{{ r.investments | number: '1.2-2' }}{{ r.advances | number: '1.2-2' }}{{ r.otherAssets | number: '1.2-2' }}
{{ r.label }}{{ r.capital | number: '1.2-2' }}{{ r.reserveProv | number: '1.2-2' }}{{ r.deposits | number: '1.2-2' }}{{ r.borrowings | number: '1.2-2' }}{{ r.otherLiabilities | number: '1.2-2' }}{{ r.cashOnHand | number: '1.2-2' }}{{ r.bankCurrent | number: '1.2-2' }}{{ r.bankFds | number: '1.2-2' }}{{ r.investments | number: '1.2-2' }}{{ r.advances | number: '1.2-2' }}{{ r.otherAssets | number: '1.2-2' }}
-
- -
-
Print Date : {{ statementPrintedAt }}
-
Report Ref No : {{ bank.reportRefNo }}  User Code:{{ statementUserCode }}
-
-
-
-
- - - -
-
-

{{ bank.formCode }} — Statement of Structural Liquidity

- -
- -
-
-
- {{ bank.name }} — {{ bank.branch }} -
-
-   As On {{ bank.asOnDate }} -   {{ bank.quarter }} -   Ref: {{ bank.reportRefNo }} -
-
- -

A. NDTL & Reference

- - - - - - - - - - - -
Net Demand & Time Liabilities (NDTL) (₹ Lakhs) - {{ bank.ndtl | number: '1.2-2' }} -
RBI Master Direction{{ bank.rbiCircular }}
- -

B. Maturity Ladder (₹ in Lakhs)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
BucketTime PeriodOutflowsInflowsMismatchCum. MismatchCum. % of Outflow
{{ b.code }}{{ b.label }}{{ b.outflow | number: '1.2-2' }}{{ b.inflow | number: '1.2-2' }} - {{ bucketGap(b) >= 0 ? '+' : '' }}{{ bucketGap(b) | number: '1.2-2' }} - - {{ cumulativeGap(i) >= 0 ? '+' : '' }}{{ cumulativeGap(i) | number: '1.2-2' }} - - {{ cumulativeGapPct(i) >= 0 ? '+' : '' }}{{ cumulativeGapPct(i).toFixed(2) }}% -
TOTAL - {{ totalOutflow | number: '1.2-2' }} - - {{ totalInflow | number: '1.2-2' }} - - {{ totalGap >= 0 ? '+' : '' }}{{ totalGap | number: '1.2-2' }} -
- -

C. RBI Tolerance Compliance

- - - - - - - - - - - - - - - -
Cumulative Negative Gap up to 14 days - {{ gap14dPct.toFixed(2) }}% - Limit: -{{ bank.tolerance14d }}% - - {{ gap14dWithinTolerance ? 'WITHIN' : 'BREACH' }} - -
Cumulative Negative Gap up to 30 days - {{ gap28dPct.toFixed(2) }}% - Limit: -{{ bank.tolerance28d }}% - - {{ gap28dWithinTolerance ? 'WITHIN' : 'BREACH' }} - -
- -
-
-
Certified by
-
{{ bank.principalOfficer }}
-
Principal Officer — {{ bank.name }}
-
-
-
Submitted under
-
{{ bank.rbiCircular }}
-
RBI Master Direction on Asset Liability Management
-
-
-
-
-
-
diff --git a/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.scss b/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.scss index 9be650584..3f99d5392 100644 --- a/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.scss +++ b/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.scss @@ -7,16 +7,6 @@ */ :host { - --alm-good: #15803d; - --alm-good-l: #dcfce7; - --alm-mid: #d97706; - --alm-mid-l: #fef3c7; - --alm-low: #c0392b; - --alm-low-l: #fdecea; - --alm-blue: #2563eb; - --alm-blue-l: #dbeafe; - --alm-indigo: #4f46e5; - --alm-indigo-l: #e0e7ff; --alm-g50: #f8fafc; --alm-g100: #f1f5f9; --alm-g200: #e2e8f0; @@ -24,6 +14,11 @@ --alm-g500: #64748b; --alm-g700: #334155; --alm-g900: #0f172a; + --alm-indigo: #4f46e5; + --alm-indigo-l: #e0e7ff; + --alm-blue: #2563eb; + --alm-blue-l: #dbeafe; + --alm-mid: #b45309; display: block; font-family: @@ -43,102 +38,59 @@ max-height: 90vh; } -/* HEADER */ +/* ── Window chrome ── */ .alm-head { - padding: 18px 22px; + padding: 16px 20px; border-bottom: 1px solid var(--alm-g200); display: flex; - align-items: center; + align-items: flex-start; + justify-content: space-between; gap: 16px; - flex-shrink: 0; - background: linear-gradient(135deg, #eef2ff 0%, #fff 100%); + background: linear-gradient(180deg, var(--alm-g50), #fff); } .alm-head-left { display: flex; - align-items: center; - gap: 14px; - flex: 1; + align-items: flex-start; + gap: 13px; } .alm-head-ic { - width: 44px; - height: 44px; - border-radius: 11px; - background: var(--alm-indigo-l); - color: var(--alm-indigo); display: flex; align-items: center; justify-content: center; - font-size: 18px; - flex-shrink: 0; + width: 38px; + height: 38px; + flex: 0 0 auto; + border-radius: 10px; + background: var(--alm-indigo-l); + color: var(--alm-indigo); + font-size: 17px; } .alm-title { margin: 0; font-size: 17px; font-weight: 700; - color: var(--alm-g900); + letter-spacing: -0.01em; } .alm-sub { - margin: 2px 0 0; + margin: 3px 0 0; font-size: 12px; color: var(--alm-g500); } -.alm-head-actions { - display: flex; - align-items: center; - gap: 12px; -} - -.alm-overall { - display: flex; - align-items: baseline; - gap: 6px; - padding: 6px 14px; - border-radius: 999px; - font-weight: 700; - - &.on-target { - background: var(--alm-good-l); - color: var(--alm-good); - } - - &.below { - background: var(--alm-low-l); - color: var(--alm-low); - } - - .alm-overall-l { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.06em; - font-weight: 600; - } - - .alm-overall-v { - font-size: 16px; - } - - .alm-overall-t { - font-size: 11px; - font-weight: 600; - opacity: 0.8; - } -} - .alm-close { - width: 32px; - height: 32px; - border-radius: 8px; + flex: 0 0 auto; + width: 30px; + height: 30px; border: 1px solid var(--alm-g200); + border-radius: 8px; background: #fff; - font-size: 14px; color: var(--alm-g500); + font-size: 13px; cursor: pointer; - transition: all 0.15s; &:hover { background: var(--alm-g100); @@ -146,679 +98,242 @@ } } -/* TABS */ -.alm-tabs { +/* ── Report parameters ── */ +.alm-params { display: flex; - background: var(--alm-g50); - border-bottom: 1px solid var(--alm-g200); - padding: 0 22px; - overflow-x: auto; - flex-shrink: 0; -} - -.alm-tab { - display: flex; - align-items: center; - padding: 12px 18px; - border: none; - background: transparent; - cursor: pointer; - font-family: inherit; - font-size: 13px; - font-weight: 600; - color: var(--alm-g500); - border-bottom: 2px solid transparent; - transition: all 0.15s; - white-space: nowrap; - - &:hover { - color: var(--alm-g700); - background: rgb(255 255 255 / 50%); - } - - &.active { - background: #fff; - color: var(--alm-indigo); - border-bottom-color: var(--alm-indigo); - } -} - -/* BODY */ -.alm-body { - padding: 18px 22px; - display: flex; - flex-direction: column; - gap: 18px; - overflow: auto; - flex: 1; -} - -/* KPI STATS */ -.alm-stats { - display: grid; - grid-template-columns: 220px 1fr 1fr 1fr; + flex-wrap: wrap; + align-items: flex-end; gap: 14px; + padding: 13px 20px; + border-bottom: 1px solid var(--alm-g200); + background: var(--alm-g50); } -.alm-stat-card { - background: #fff; - border: 1px solid var(--alm-g200); - border-radius: 12px; - padding: 14px 16px; +.alm-param { display: flex; flex-direction: column; gap: 4px; - box-shadow: 0 1px 3px rgb(0 0 0 / 4%); -} -.alm-stat-l { - font-size: 11px; - font-weight: 600; - color: var(--alm-g500); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.alm-stat-v { - font-size: 20px; - font-weight: 700; - color: var(--alm-g900); - line-height: 1.2; -} - -.alm-stat-meta { - font-size: 11px; - color: var(--alm-g500); - margin-top: 4px; - - &.pos { - color: var(--alm-good); + > span { + font-size: 10px; font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--alm-g500); } - &.neg { - color: var(--alm-low); - font-weight: 600; + select { + min-width: 130px; + padding: 6px 9px; + border: 1px solid var(--alm-g300); + border-radius: 6px; + background: #fff; + font: inherit; + font-size: 12.5px; + color: inherit; } } -.alm-stat-gauge { - align-items: center; - justify-content: center; -} - -/* GAUGE */ -.gauge { - width: 140px; - height: 140px; - border-radius: 50%; - background: conic-gradient(currentcolor calc(var(--pct) * 1%), var(--alm-g100) 0); - display: flex; - align-items: center; - justify-content: center; - - &.on-target { - color: var(--alm-good); - } - - &.below { - color: var(--alm-low); - } -} - -.gauge-inner { - width: 108px; - height: 108px; - border-radius: 50%; - background: #fff; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - box-shadow: inset 0 0 0 1px var(--alm-g200); -} - -.gauge-pct { - font-size: 20px; - font-weight: 700; - color: var(--alm-g900); - line-height: 1; -} - -.gauge-label { - font-size: 10px; - color: var(--alm-g500); - margin-top: 4px; - text-transform: uppercase; - letter-spacing: 0.06em; - font-weight: 600; -} - -/* SECTIONS */ -.alm-section { - background: #fff; +/* A parameter the report fixes rather than asks for */ +.alm-param-static { + min-width: 90px; + padding: 6px 9px; border: 1px solid var(--alm-g200); - border-radius: 12px; - padding: 16px 18px; - box-shadow: 0 1px 3px rgb(0 0 0 / 4%); -} - -.alm-sec-h { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - margin-bottom: 14px; - flex-wrap: wrap; -} - -.alm-sec-t { - margin: 0; - font-size: 14px; - font-weight: 700; + border-radius: 6px; + background: var(--alm-g100); + font-size: 12.5px; + font-weight: 600; color: var(--alm-g700); } -.alm-sec-actions { - display: flex; - gap: 8px; - align-items: center; +.alm-params-sp { + flex: 1 1 auto; } -.alm-sel { - height: 34px; - padding: 0 10px; - border: 1px solid var(--alm-g200); - border-radius: 6px; +.alm-btn { + display: inline-flex; + align-items: center; + padding: 7px 14px; + border: 1px solid var(--alm-g300); + border-radius: 7px; background: #fff; - font-family: inherit; - font-size: 12px; - color: var(--alm-g900); - - &:focus { - outline: none; - border-color: var(--alm-indigo); - box-shadow: 0 0 0 3px rgb(79 70 229 / 12%); - } -} - -.alm-circular { - font-family: 'DM Mono', monospace; - font-size: 11px; - color: var(--alm-g500); - padding: 4px 10px; - background: var(--alm-g50); - border-radius: 6px; - border: 1px solid var(--alm-g200); -} - -.alm-btn-xls { - font-size: 12px !important; - height: 34px !important; - color: #fff !important; - background: var(--alm-indigo) !important; -} - -/* TOLERANCE BARS */ -.alm-tolerance { - display: flex; - flex-direction: column; - gap: 16px; -} - -.alm-tol-row { - display: flex; - flex-direction: column; - gap: 6px; - - &.good .alm-tol-fill { - background: var(--alm-good); - } - - &.bad .alm-tol-fill { - background: var(--alm-low); - } - - &.good .alm-tol-st { - background: var(--alm-good-l); - color: var(--alm-good); - } - - &.bad .alm-tol-st { - background: var(--alm-low-l); - color: var(--alm-low); - } -} - -.alm-tol-h { - display: flex; - justify-content: space-between; - align-items: center; -} - -.alm-tol-l { - font-size: 12px; + font: inherit; + font-size: 12.5px; font-weight: 600; + color: var(--alm-g900); + cursor: pointer; + + &:hover { + background: var(--alm-g100); + } +} + +/* States which columns the extract does not map, so nil is not read as a real balance */ +.alm-note { + display: flex; + align-items: flex-start; + gap: 7px; + margin: 0; + padding: 9px 20px; + border-bottom: 1px solid var(--alm-g200); + background: color-mix(in srgb, var(--alm-blue) 4%, #fff); + font-size: 11.5px; + line-height: 1.55; color: var(--alm-g700); fa-icon { - color: var(--alm-indigo); + flex: 0 0 auto; + margin-top: 1px; + color: var(--alm-blue); } -} - -.alm-tol-p { - display: flex; - align-items: center; - gap: 10px; - font-size: 13px; strong { - font-size: 14px; - color: var(--alm-g900); - } - - .alm-tol-lim { - font-size: 11px; - color: var(--alm-g500); - } - - .alm-tol-st { - font-size: 10px; - font-weight: 700; - padding: 2px 8px; - border-radius: 999px; + color: var(--alm-mid); } } -.alm-tol-track { - position: relative; - height: 8px; - background: var(--alm-g100); - border-radius: 4px; - overflow: hidden; -} - -.alm-tol-fill { - height: 100%; - border-radius: 4px; - transition: width 0.4s ease-out; -} - -.alm-tol-marker { - position: absolute; - top: -2px; - bottom: -2px; - left: 50%; - width: 2px; - background: var(--alm-g500); -} - -/* HEATMAP */ -.alm-heatmap { - display: grid; - grid-template-columns: repeat(11, 1fr); - gap: 6px; -} - -.heat-cell { - display: flex; - flex-direction: column; - align-items: center; - padding: 10px 6px; - border-radius: 8px; - text-align: center; - cursor: default; - transition: transform 0.15s; - - &:hover { - transform: translateY(-2px); - box-shadow: 0 4px 8px rgb(0 0 0 / 8%); - } - - &.surplus { - background: var(--alm-good-l); - color: var(--alm-good); - } - - &.deficit { - background: var(--alm-low-l); - color: var(--alm-low); - } - - &.balanced { - background: var(--alm-g100); - color: var(--alm-g500); - } -} - -.heat-code { - font-family: 'DM Mono', monospace; - font-size: 10px; - font-weight: 700; - opacity: 0.7; -} - -.heat-pct { - font-size: 14px; - font-weight: 800; - margin-top: 2px; -} - -.heat-lbl { - font-size: 9px; - margin-top: 4px; - opacity: 0.85; - line-height: 1.1; -} - -/* INFO GRID */ -.alm-info-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 12px 24px; -} - -.alm-info { - display: flex; - flex-direction: column; - gap: 2px; -} - -.alm-info-l { - font-size: 10px; - font-weight: 600; - color: var(--alm-g500); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.alm-info-v { - font-size: 13px; - font-weight: 600; - color: var(--alm-g900); -} - -/* TABLE */ -.alm-tbl-wrap { - border: 1px solid var(--alm-g200); - border-radius: 8px; +.alm-body { + padding: 20px; overflow: auto; - margin-bottom: 12px; + background: var(--alm-g100); } -.alm-tbl { +/* ── The statement — plain, borderless, print-shaped ── */ +.stmt { + padding: 26px 30px 20px; + background: #fff; + box-shadow: 0 1px 4px rgb(0 0 0 / 8%); +} + +.stmt-bank { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.01em; +} + +.stmt-title { + margin-top: 3px; + margin-bottom: 20px; + font-size: 11px; + color: var(--alm-g700); +} + +.stmt-tbl-wrap { + overflow-x: auto; +} + +/* No rules or shading between rows: the printed statement has none */ +.stmt-tbl { width: 100%; border-collapse: collapse; + font-size: 11px; th { - text-align: left; - padding: 10px 14px; - font-size: 11px; - font-weight: 600; - color: var(--alm-g500); - text-transform: uppercase; - letter-spacing: 0.05em; - background: var(--alm-g50); - border-bottom: 1px solid var(--alm-g200); + padding: 0 10px 10px; + text-align: right; + vertical-align: bottom; + font-weight: 400; + font-size: 10px; + line-height: 1.3; + color: var(--alm-g700); white-space: nowrap; } - td { - padding: 10px 14px; - border-bottom: 1px solid var(--alm-g100); - font-size: 13px; - color: var(--alm-g700); - vertical-align: middle; - } - - tr:last-child td { - border-bottom: none; - } - - tr:hover td { - background: var(--alm-g50); - } - - .row-total td { - background: var(--alm-indigo-l); - color: var(--alm-g900); - font-weight: 700; - border-top: 2px solid var(--alm-indigo); - } - - .grp-liab { - background: var(--alm-low-l) !important; - color: var(--alm-low) !important; - } - - .grp-asset { - background: var(--alm-good-l) !important; - color: var(--alm-good) !important; - } -} - -/* AVERAGE LIABILITIES AND ASSETS — laid out as the statement prints */ -.alm-stmt { - padding: 16px; - border: 1px solid var(--alm-g200); - border-radius: 10px; - background: #fff; -} - -.alm-stmt-bank { - font-size: 13px; - font-weight: 700; - color: var(--alm-g900); -} - -.alm-stmt-title { - margin: 2px 0 14px; - font-size: 12px; - color: var(--alm-g500); -} - -.alm-stmt-tbl { - th, td { padding: 7px 10px; - font-size: 12px; + text-align: right; + font-weight: 700; + font-variant-numeric: tabular-nums; + white-space: nowrap; } - th { - text-transform: none; - letter-spacing: 0; - white-space: normal; + .col-month { + text-align: left; } - td.ta-r { - font-family: 'DM Mono', monospace; + .col-grp { + text-align: center; + padding-bottom: 3px; } } -.alm-stmt-sub { - font-size: 10px; - color: var(--alm-g500); -} - -.alm-stmt-foot { - margin-top: 14px; - padding-top: 10px; - border-top: 1px solid var(--alm-g200); - font-size: 11px; - line-height: 1.7; - color: var(--alm-g500); -} - -.status-pill { - display: inline-block; - padding: 3px 10px; - font-size: 11px; - font-weight: 600; - border-radius: 999px; - - &.on-target, - &.surplus { - background: var(--alm-good-l); - color: var(--alm-good); - } - - &.below, - &.deficit { - background: var(--alm-low-l); - color: var(--alm-low); - } - - &.balanced { - background: var(--alm-g100); - color: var(--alm-g500); - } -} - -.ta-c { - text-align: center; -} - -.ta-r { - text-align: right; -} - -.mono { - font-family: 'DM Mono', monospace; - font-size: 12px; -} - -.pos { - color: var(--alm-good); -} - -.neg { - color: var(--alm-low); -} - -/* FORM ALM-1 */ -.form-iii { - background: var(--alm-g50); - border: 1px solid var(--alm-g200); - border-radius: 10px; - padding: 18px; -} - -.form-iii-h { - display: flex; - justify-content: space-between; - align-items: center; - padding-bottom: 12px; - margin-bottom: 14px; - border-bottom: 2px solid var(--alm-indigo); - flex-wrap: wrap; - gap: 10px; -} - -.form-iii-bank { - font-size: 14px; - color: var(--alm-g900); -} - -.form-iii-meta { - display: flex; - gap: 18px; - font-size: 12px; - color: var(--alm-g500); - flex-wrap: wrap; - - fa-icon { - color: var(--alm-indigo); - } -} - -.form-iii-t { - font-size: 13px; +.stmt-yr { font-weight: 700; +} + +/* Total and Average close the statement; ruled off above, as on the print */ +.stmt-sum td { + padding-top: 13px; + font-weight: 700; +} + +.stmt-sum:first-of-type td { + border-top: 1px solid var(--alm-g300); +} + +.stmt-sum .col-month { + font-weight: 400; +} + +.stmt-foot { + margin-top: 34px; + font-size: 10px; + line-height: 1.8; color: var(--alm-g700); - margin: 16px 0 8px; - padding-bottom: 4px; - border-bottom: 1px dashed var(--alm-g200); } -.form-tbl { - background: #fff; - border: 1px solid var(--alm-g200); - border-radius: 6px; - overflow: hidden; -} - -.form-total td { - background: var(--alm-indigo-l) !important; - font-weight: 700; - color: var(--alm-g900); -} - -.form-iii-foot { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 18px; - margin-top: 18px; - padding-top: 16px; - border-top: 1px dashed var(--alm-g300); -} - -.cert { - background: #fff; - border: 1px solid var(--alm-g200); - border-radius: 8px; - padding: 12px 14px; -} - -.cert-l { - font-size: 10px; - font-weight: 600; - color: var(--alm-g500); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.cert-v { - font-size: 14px; - font-weight: 700; - color: var(--alm-g900); - margin: 4px 0; -} - -.cert-r { - font-size: 11px; - color: var(--alm-g500); -} - -/* RESPONSIVE */ -@media (width <= 1100px) { - .alm-stats { - grid-template-columns: 1fr 1fr; +/* ── Print: the statement alone, on paper, landscape ── */ +@media print { + /* stylelint-disable unit-allowed-list */ + :host { + display: block; } - .alm-info-grid { - grid-template-columns: 1fr 1fr; + .no-print { + display: none !important; } - .form-iii-foot { - grid-template-columns: 1fr; + .alm-dlg { + max-height: none; + border-radius: 0; + overflow: visible; } - .alm-heatmap { - grid-template-columns: repeat(6, 1fr); - } -} - -@media (width <= 720px) { - .alm-stats, - .alm-info-grid { - grid-template-columns: 1fr; - } - - .alm-head { - flex-wrap: wrap; - } - - .alm-heatmap { - grid-template-columns: repeat(3, 1fr); + .alm-body { + padding: 0; + overflow: visible; + background: #fff; } + + .stmt { + padding: 0; + box-shadow: none; + } + + .stmt-tbl-wrap { + overflow: visible; + } + + .stmt-tbl { + font-size: 8.5pt; + + th { + font-size: 7.5pt; + } + } + + /* Keep a month's two lines and the closing block from splitting across pages */ + .stmt-tbl tr { + page-break-inside: avoid; + } + + .stmt-foot { + margin-top: 24pt; + } + + @page { + size: a4 landscape; + margin: 12mm; + } + /* stylelint-enable unit-allowed-list */ } diff --git a/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.ts b/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.ts index 1fae7ce6e..3ee347d2b 100644 --- a/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.ts +++ b/src/app/home/dashboard/alm-report-dialog/alm-report-dialog.component.ts @@ -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; - 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()); /** " , " — 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(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( + `Average Liabilities and Assets — ${this.bank.financialYear}` + + `${node.outerHTML}` + ); + 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}` + ); } }