feat: add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-08-11 11:11:18 +05:30
parent 01e86173c1
commit 7b895a0e2b
77 changed files with 10046 additions and 1130 deletions
@@ -1750,6 +1750,38 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
return salutation ? `${salutation} ${full}` : full;
}
/**
* The name PAN and Aadhaar are actually matched against.
*
* Deliberately not {@link fullNamePreview}: that prefixes the salutation, while
* ClientKycWritePlatformServiceJpaRepositoryImpl builds the verification name from firstname +
* middlename + lastname only. Values are passed through untouched rather than capitalised, so what
* the KYC step displays is exactly what goes to the provider.
*/
get verificationName(): string {
return [
this.createClientForm?.get('firstname')?.value,
this.createClientForm?.get('middlename')?.value,
this.createClientForm?.get('lastname')?.value
]
.map((part: any) => (part || '').toString().trim())
.filter(Boolean)
.join(' ');
}
/** Date of birth as captured, sent to the provider formatted dd/MM/yyyy. */
get verificationDateOfBirth(): Date | null {
const value = this.createClientForm?.get('dateOfBirth')?.value;
if (!value) {
return null;
}
if (value instanceof Date) {
return value;
}
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
getSelectedGender(): any {
const val = this.createClientForm.get('genderId')?.value;
return this.genderOptions?.find((g: any) => g.id === val) || null;
@@ -38,10 +38,17 @@ import { ClientsService } from 'app/clients/clients.service';
}
</div>
<p class="sanctions-msg">
Screening <strong>{{ clientName }}</strong> against<br />
<strong>UN Security Council</strong> &amp;
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
Screening <strong>{{ clientName }}</strong> against the<br />
<strong>UN Security Council Consolidated List</strong><br />
<span class="sanctions-lists"
>ISIL (Da&rsquo;esh) &amp; Al-Qaida (1267/1989/2253) and Taliban (1988) sanctions lists</span
>
</p>
@if (listDataAsOf) {
<p class="list-as-of">
Sanctions list data as of <strong>{{ listDataAsOf }}</strong>
</p>
}
<p class="regulatory-note">{{ regulatoryBasis }}</p>
</div>
}
@@ -54,8 +61,8 @@ import { ClientsService } from 'app/clients/clients.service';
<h3 class="approve-title">No Sanctions Match Found</h3>
<p class="approve-detail">
<strong>{{ clientName }}</strong> has been screened against the
<strong>UN Security Council Consolidated List</strong> and <strong>FIU-IND</strong> records. No confirmed
match was found.
<strong>UN Security Council Consolidated List</strong> (ISIL &amp; Al-Qaida and Taliban sanctions lists). No
confirmed match was found.
</p>
<div class="approve-meta">
<div class="meta-row">
@@ -222,6 +229,20 @@ import { ClientsService } from 'app/clients/clients.service';
text-align: center;
}
.list-as-of {
font-size: 12px;
color: #374151;
text-align: center;
margin: 4px 0 0;
}
.sanctions-lists {
display: inline-block;
margin-top: 4px;
font-size: 12px;
color: #6b7280;
}
.regulatory-note {
font-size: 11px;
color: #9ca3af;
@@ -550,16 +571,24 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
screeningLogId: number | null = null;
listDataAsOf: string | null = null;
/**
* Cites the direction actually in force. Chapter IX ("Requirements/obligations under International
* Agreements") is the clause that puts the UNSC lists in scope, and the 2025 Directions superseded
* the older Master Direction this screen used to name.
*/
regulatoryBasis =
'Screened as per Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and the RBI Master Direction on KYC.';
'Screened under Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and Chapter IX of the ' +
'RBI Know Your Customer Directions, 2025 (amended as on December 29, 2025).';
screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
progressValue = 0;
currentStep = 0;
// Steps describe the screening that actually runs against the UNSC consolidated list; there is no
// FIU-IND lookup behind this call, so it is no longer claimed here.
progressSteps = [
{ label: 'Connecting to UN Security Council database...', icon: 'globe' as any },
{ label: 'Screening name against consolidated list...', icon: 'search' as any },
{ label: 'Cross-checking FIU-IND records...', icon: 'database' as any },
{ label: 'Cross-checking listed aliases and entities...', icon: 'database' as any },
{ label: 'Analyzing results...', icon: 'chart-bar' as any }
];
@@ -577,6 +606,9 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
next: (result: any) => {
this.apiResult = result;
this.apiDone = true;
// Surfaced while the screening is still running, so the operator can see which snapshot of
// the UNSC list the check is being made against rather than only learning it afterwards.
this.listDataAsOf = this.formatListDate(result?.listDataAsOf);
},
error: (err: any) => {
this.apiError = err;
@@ -652,13 +684,7 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
'Unable to connect to sanctions screening service.';
} else {
this.screeningLogId = this.apiResult?.screeningLogId || null;
this.listDataAsOf = this.apiResult?.listDataAsOf
? new Date(this.apiResult.listDataAsOf).toLocaleDateString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric'
})
: null;
this.listDataAsOf = this.formatListDate(this.apiResult?.listDataAsOf);
if (this.apiResult?.matchFound) {
this.matchFound = true;
this.matchCount = this.apiResult.matchCount || 0;
@@ -668,6 +694,18 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
}, 400);
}
/** Formats the date the UNSC snapshot was loaded; null when the backend did not report one. */
private formatListDate(value: string | null | undefined): string | null {
if (!value) {
return null;
}
const parsed = new Date(value);
if (isNaN(parsed.getTime())) {
return null;
}
return parsed.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
}
ngOnDestroy() {
clearInterval(this.progressTimer);
}
@@ -56,6 +56,42 @@
}
}
<!-- What PAN / Aadhaar are matched against. Shown before the officer clicks Verify, because
the provider matches on exactly these two values and returns nothing identifiable when
they disagree — a blind check that fails is much harder to act on than a visible one. -->
@if (!isNonIndividual) {
<div
class="identity-basis full-width"
[class.identity-basis--incomplete]="!verificationName || !verificationDateOfBirth"
>
<mat-icon class="identity-basis__icon">badge</mat-icon>
<div class="identity-basis__body">
<span class="identity-basis__legend">Documents are verified against the General tab</span>
<div class="identity-basis__pairs">
<span class="identity-basis__pair">
<span class="identity-basis__k">Name</span>
<span class="identity-basis__v">{{ verificationName || 'Not entered' }}</span>
</span>
<span class="identity-basis__pair">
<span class="identity-basis__k">Date of Birth</span>
<span class="identity-basis__v">
{{ verificationDateOfBirth ? (verificationDateOfBirth | date: 'dd MMM yyyy') : 'Not entered' }}
</span>
</span>
</div>
@if (!verificationName || !verificationDateOfBirth) {
<span class="identity-basis__warn">
Both are required for PAN and Aadhaar verification — complete them on the General tab first.
</span>
} @else {
<span class="identity-basis__hint">
Must match the card exactly. Salutation is excluded; a mismatch in either fails verification.
</span>
}
</div>
</div>
}
<!-- Individual PAN with inline verify -->
@if (!isMinor && kycForm.get('hasPan')?.value) {
<div class="form-field verify-field">
@@ -105,8 +141,109 @@
@if (panVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
@if (panVerifyStatus !== 'VERIFIED' && panDeclarationTaken) {
<span class="verify-chip declared"><fa-icon icon="file-signature"></fa-icon> Declaration</span>
}
</div>
</div>
@if (panVerifyStatus === 'VERIFIED') {
<div
class="pal-card full-width"
[class.pal-card--ok]="panAadhaarSeedingStatus === 'Y'"
[class.pal-card--fail]="panAadhaarSeedingStatus === 'N'"
[class.pal-card--info]="panAadhaarSeedingStatus === 'E'"
[class.pal-card--pending]="
panAadhaarSeedingStatus !== 'Y' && panAadhaarSeedingStatus !== 'N' && panAadhaarSeedingStatus !== 'E'
"
style="margin-bottom: 16px"
>
<div class="pal-card__left">
<div class="pal-card__icon">
<mat-icon>credit_card</mat-icon>
</div>
<div class="pal-card__txt">
<div class="pal-card__small">Verification Status</div>
<div class="pal-card__title">PAN &harr; Aadhaar</div>
<div class="pal-card__status">
@if (panAadhaarSeedingStatus === 'Y') {
<mat-icon>check_circle</mat-icon> Linked (Seeded)
} @else if (panAadhaarSeedingStatus === 'N') {
<mat-icon>cancel</mat-icon> Not Linked
} @else if (panAadhaarSeedingStatus === 'E') {
<mat-icon>info</mat-icon> Exempt
} @else {
<mat-icon>radio_button_unchecked</mat-icon> Pending
}
</div>
</div>
</div>
<div class="pal-card__badge">
@if (panAadhaarSeedingStatus === 'Y') {
<mat-icon>check</mat-icon>
} @else if (panAadhaarSeedingStatus === 'N') {
<mat-icon>close</mat-icon>
} @else if (panAadhaarSeedingStatus === 'E') {
<mat-icon>info</mat-icon>
} @else {
<mat-icon>schedule</mat-icon>
}
</div>
<div class="pal-card__hint">As per Income Tax Portal &mdash; Section 139AA</div>
</div>
}
<!--
PAN could not be verified but a declaration is on file. Shown in full because it is the
only thing standing between this client and a PAN KYC exception — an officer reviewing
the record has to be able to see what was declared, not just that something was.
-->
@if (panVerifyStatus !== 'VERIFIED' && panDeclarationTaken) {
<div class="pan-decl-card">
<div class="pan-decl-card__head">
<mat-icon class="pan-decl-card__icon">assignment_turned_in</mat-icon>
<span class="pan-decl-card__title">PAN Declaration Taken</span>
@if (panDeclarationTakenOn) {
<span class="pan-decl-card__date">{{ panDeclarationTakenOn }}</span>
}
</div>
<div class="pan-decl-card__rows">
<div class="pan-decl-card__row">
<span class="k">Does not match</span>
<span class="v strong">{{ panDeclarationMismatchLabel }}</span>
</div>
@if (panDeclaration?.nameOnPan) {
<div class="pan-decl-card__row">
<span class="k">Name on PAN</span>
<span class="v">{{ panDeclaration?.nameOnPan }}</span>
</div>
}
@if (panDeclaration?.dobOnPan) {
<div class="pan-decl-card__row">
<span class="k">DOB on PAN</span>
<span class="v">{{ panDeclaration?.dobOnPan | date: 'dd MMMM yyyy' }}</span>
</div>
}
@if (panDeclaration?.fileName) {
<div class="pan-decl-card__row">
<span class="k">Declaration file</span>
<span class="v">{{ panDeclaration?.fileName }}</span>
</div>
}
</div>
<div class="pan-decl-card__note">
PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file.
</div>
@if (!readOnly) {
<div class="pan-decl-card__actions">
<button mat-stroked-button type="button" (click)="openPanDeclaration()">Edit Declaration</button>
<button mat-stroked-button type="button" class="danger" (click)="removePanDeclaration()">
Remove
</button>
</div>
}
</div>
}
}
<!-- Aadhaar with independent verify (Individual only) -->
@@ -175,9 +312,68 @@
@if (aadhaarVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
@if (aadhaarVerifyStatus !== 'VERIFIED' && aadhaarReceiptTaken) {
<span class="verify-chip declared"><fa-icon icon="receipt"></fa-icon> Receipt</span>
}
</div>
</div>
<!--
A customer who has applied for Aadhaar but not received it has no number to verify, so no
amount of retrying the OTP will clear the check. Offer the enrolment slip instead rather
than leaving the officer unable to finish the client.
-->
@if (aadhaarVerifyStatus !== 'VERIFIED' && !aadhaarReceiptTaken && !aadhaarOtpSent && !readOnly) {
<div class="aad-rcpt-offer full-width">
<mat-icon>receipt_long</mat-icon>
<span>Customer has applied for Aadhaar but not received it yet?</span>
<button mat-stroked-button type="button" (click)="openAadhaarReceipt()">Record Enrolment Receipt</button>
</div>
}
<!--
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
only thing standing between this client and an Aadhaar KYC exception.
-->
@if (aadhaarVerifyStatus !== 'VERIFIED' && aadhaarReceiptTaken) {
<div class="aad-rcpt-card full-width">
<div class="aad-rcpt-card__head">
<mat-icon class="aad-rcpt-card__icon">receipt_long</mat-icon>
<span class="aad-rcpt-card__title">Aadhaar Enrolment Receipt Recorded</span>
@if (aadhaarReceiptTakenOn) {
<span class="aad-rcpt-card__date">{{ aadhaarReceiptTakenOn }}</span>
}
</div>
<div class="aad-rcpt-card__rows">
<div class="aad-rcpt-card__row">
<span class="k">Enrolment ID</span>
<span class="v mono">{{ aadhaarReceipt?.enrolmentId }}</span>
</div>
@if (aadhaarReceipt?.enrolmentDate) {
<div class="aad-rcpt-card__row">
<span class="k">Enrolment date</span>
<span class="v">{{ aadhaarReceipt?.enrolmentDate | date: 'dd MMMM yyyy' }}</span>
</div>
}
@if (aadhaarReceipt?.remarks) {
<div class="aad-rcpt-card__row">
<span class="k">Remarks</span>
<span class="v">{{ aadhaarReceipt?.remarks }}</span>
</div>
}
</div>
<div class="aad-rcpt-card__note">
Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file.
</div>
@if (!readOnly) {
<div class="aad-rcpt-card__actions">
<button mat-stroked-button type="button" (click)="openAadhaarReceipt()">Edit Receipt</button>
<button mat-stroked-button type="button" class="danger" (click)="removeAadhaarReceipt()">Remove</button>
</div>
}
</div>
}
<!-- Aadhaar OTP Section -->
@if (aadhaarOtpSent && aadhaarVerifyStatus !== 'VERIFIED') {
<div class="otp-card full-width">
@@ -725,51 +921,53 @@
}
<!-- KYC Exception Flag -->
<div
class="exception-flag-box"
style="
margin-top: 20px;
padding: 16px;
border: 1px solid #f59e0b;
background: #fffbeb;
border-radius: 8px;
clear: both;
"
>
<h5
@if (!kycAllDone) {
<div
class="exception-flag-box"
style="
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #b45309;
display: flex;
align-items: center;
gap: 8px;
margin-top: 20px;
padding: 16px;
border: 1px solid #f59e0b;
background: #fffbeb;
border-radius: 8px;
clear: both;
"
>
<fa-icon icon="exclamation-triangle"></fa-icon>
KYC Exception Flag
</h5>
<div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap">
<mat-checkbox formControlName="isException">Mark as KYC Exception</mat-checkbox>
<h5
style="
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #b45309;
display: flex;
align-items: center;
gap: 8px;
"
>
<fa-icon icon="exclamation-triangle"></fa-icon>
KYC Exception Flag
</h5>
<div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap">
<mat-checkbox formControlName="isException">Mark as KYC Exception</mat-checkbox>
@if (kycForm.get('isException')?.value) {
<mat-form-field appearance="outline" style="flex: 1; min-width: 280px; margin-bottom: -1.25em">
<mat-label>Exception Reasons</mat-label>
<mat-select formControlName="exceptionReason" multiple>
@if (!isNonIndividual) {
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
} @else {
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
}
</mat-select>
</mat-form-field>
}
@if (kycForm.get('isException')?.value) {
<mat-form-field appearance="outline" style="flex: 1; min-width: 280px; margin-bottom: -1.25em">
<mat-label>Exception Reasons</mat-label>
<mat-select formControlName="exceptionReason" multiple>
@if (!isNonIndividual) {
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
} @else {
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
</div>
</div>
}
</div>
<!-- ═══ SECTION: KYC Document Management ═══ -->
@@ -847,19 +1045,23 @@
<!-- Existing saved ID-proof doc — locked by default. Replace flow
unlocks automatically when the section-level Edit button toggles
`readOnly` off. -->
@if (existingKycDocuments.length > 0 && !kycDocFiles.front && readOnly) {
@if (existingKycDocSides.front && !kycDocFiles.front && readOnly) {
<div class="kyc-doc-dropzone kyc-doc-dropzone--has kyc-doc-dropzone--locked">
<div class="kyc-doc-dropzone__existing">
@if (kycDocMergedPreview) {
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
@if (existingKycDocSides.front?.previewUrl) {
<img
[src]="existingKycDocSides.front?.previewUrl"
alt="Saved front side"
class="kyc-doc-dropzone__thumb"
/>
} @else {
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
}
<div class="kyc-doc-dropzone__existing-meta">
<span class="kyc-doc-dropzone__existing-name">
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
{{ existingKycDocSides.front?.fileName }}
</span>
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc()">
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc('front')">
<mat-icon>visibility</mat-icon>
Click here to view
</button>
@@ -869,8 +1071,8 @@
} @else {
<label
class="kyc-doc-dropzone"
[class.kyc-doc-dropzone--has]="kycDocFiles.front || existingKycDocuments.length > 0"
[class.kyc-doc-dropzone--req]="!kycDocFiles.front && existingKycDocuments.length === 0"
[class.kyc-doc-dropzone--has]="kycDocFiles.front || existingKycDocSides.front"
[class.kyc-doc-dropzone--req]="!kycDocFiles.front && !existingKycDocSides.front"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'front')" />
@if (kycDocFiles.front) {
@@ -886,24 +1088,28 @@
<mat-icon>close</mat-icon>
</button>
</div>
} @else if (existingKycDocuments.length > 0) {
} @else if (existingKycDocSides.front) {
<!-- Edit mode WITH existing doc — let the user view it OR
click anywhere to replace. -->
<div class="kyc-doc-dropzone__existing">
@if (kycDocMergedPreview) {
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
@if (existingKycDocSides.front?.previewUrl) {
<img
[src]="existingKycDocSides.front?.previewUrl"
alt="Saved front side"
class="kyc-doc-dropzone__thumb"
/>
} @else {
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
}
<div class="kyc-doc-dropzone__existing-meta">
<span class="kyc-doc-dropzone__existing-name">
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
{{ existingKycDocSides.front?.fileName }}
</span>
<div class="kyc-doc-dropzone__existing-actions">
<button
type="button"
class="kyc-doc-dropzone__view"
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc()"
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc('front')"
>
<mat-icon>visibility</mat-icon>
View
@@ -930,25 +1136,45 @@
<div class="kyc-doc-upload-slot">
<div class="kyc-doc-upload-head">
<span class="kyc-doc-upload-title"> <mat-icon>image</mat-icon> Back side </span>
<!-- Hide Required pill when saved combined doc covers both sides -->
@if (!(existingKycDocuments.length > 0 && !kycDocFiles.back)) {
<!-- Hide Required pill when a back scan (or a stacked scan holding it) is on file -->
@if (!((existingKycDocSides.back || kycDocIsLegacyCombined) && !kycDocFiles.back)) {
<span class="kyc-doc-upload-req">Required</span>
}
</div>
<!-- Locked view: saved combined doc covers the back side too. -->
@if (existingKycDocuments.length > 0 && !kycDocFiles.back && readOnly) {
<!--
A stacked scan that could not be cut in two has no separate back image. Say so rather
than repeating the front slot's picture here.
-->
@if (kycDocIsLegacyCombined && !kycDocFiles.back) {
<div class="kyc-doc-dropzone kyc-doc-dropzone--has kyc-doc-dropzone--locked">
<div class="kyc-doc-dropzone__existing">
@if (kycDocMergedPreview) {
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
<mat-icon class="kyc-doc-dropzone__doc-ic">filter_none</mat-icon>
<div class="kyc-doc-dropzone__existing-meta">
<span class="kyc-doc-dropzone__existing-name">Included in the combined scan</span>
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc('front')">
<mat-icon>visibility</mat-icon>
View both sides
</button>
</div>
</div>
</div>
} @else if (existingKycDocSides.back && !kycDocFiles.back && readOnly) {
<div class="kyc-doc-dropzone kyc-doc-dropzone--has kyc-doc-dropzone--locked">
<div class="kyc-doc-dropzone__existing">
@if (existingKycDocSides.back?.previewUrl) {
<img
[src]="existingKycDocSides.back?.previewUrl"
alt="Saved back side"
class="kyc-doc-dropzone__thumb"
/>
} @else {
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
}
<div class="kyc-doc-dropzone__existing-meta">
<span class="kyc-doc-dropzone__existing-name">
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
{{ existingKycDocSides.back?.fileName }}
</span>
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc()">
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc('back')">
<mat-icon>visibility</mat-icon>
Click here to view
</button>
@@ -958,8 +1184,8 @@
} @else {
<label
class="kyc-doc-dropzone"
[class.kyc-doc-dropzone--has]="kycDocFiles.back || existingKycDocuments.length > 0"
[class.kyc-doc-dropzone--req]="!kycDocFiles.back && existingKycDocuments.length === 0"
[class.kyc-doc-dropzone--has]="kycDocFiles.back || existingKycDocSides.back"
[class.kyc-doc-dropzone--req]="!kycDocFiles.back && !existingKycDocSides.back"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'back')" />
@if (kycDocFiles.back) {
@@ -975,23 +1201,27 @@
<mat-icon>close</mat-icon>
</button>
</div>
} @else if (existingKycDocuments.length > 0) {
<!-- Edit mode WITH existing combined doc — view + replace. -->
} @else if (existingKycDocSides.back) {
<!-- Edit mode WITH an existing back scan — view + replace. -->
<div class="kyc-doc-dropzone__existing">
@if (kycDocMergedPreview) {
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
@if (existingKycDocSides.back?.previewUrl) {
<img
[src]="existingKycDocSides.back?.previewUrl"
alt="Saved back side"
class="kyc-doc-dropzone__thumb"
/>
} @else {
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
}
<div class="kyc-doc-dropzone__existing-meta">
<span class="kyc-doc-dropzone__existing-name">
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
{{ existingKycDocSides.back?.fileName }}
</span>
<div class="kyc-doc-dropzone__existing-actions">
<button
type="button"
class="kyc-doc-dropzone__view"
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc()"
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc('back')"
>
<mat-icon>visibility</mat-icon>
View
@@ -1027,7 +1257,9 @@
<mat-icon>filter_none</mat-icon>
Combined preview (front + back)
</span>
<span class="kyc-merged__hint">Saved as a single PNG. Re-crop a side to update the merged file.</span>
<span class="kyc-merged__hint">
Preview only — each side is saved as its own file. Re-crop a side to update it.
</span>
</div>
<div class="kyc-merged__preview">
<img [src]="kycDocMergedPreview" alt="Merged KYC document" />
@@ -1197,27 +1429,36 @@
</div>
}
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>{{ isKycVerified ? 'Reason for Re-edit *' : 'Remarks' }}</mat-label>
<textarea
matInput
formControlName="remarks"
rows="2"
[placeholder]="isKycVerified ? 'Mandatory — explain why KYC is being re-edited' : ''"
></textarea>
@if (isKycVerified && !kycForm.get('remarks')?.value?.trim()) {
<mat-error>Remarks are mandatory when updating a verified KYC record</mat-error>
}
</mat-form-field>
@if (isKycVerified) {
<div class="kyc-reedit-alert">
<mat-icon class="kyc-reedit-alert__icon">info</mat-icon>
<span
>KYC is <strong>VERIFIED</strong>. Any update will reset the status to <strong>IN PROGRESS</strong> and
require PAN re-verification.</span
>
</div>
@if (kycForm.get('allowRiskOverride')?.value || isKycVerified) {
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>
{{
isKycVerified
? 'Reason for Re-edit'
: kycForm.get('allowRiskOverride')?.value
? 'Remarks (Required for Risk Override)'
: 'Remarks'
}}
</mat-label>
<textarea
matInput
formControlName="remarks"
rows="2"
[placeholder]="
isKycVerified
? 'Mandatory — explain why KYC is being re-edited'
: kycForm.get('allowRiskOverride')?.value
? 'Mandatory — explain why you are overriding the auto-calculated risk category'
: ''
"
></textarea>
@if ((isKycVerified || kycForm.get('allowRiskOverride')?.value) && !kycForm.get('remarks')?.value?.trim()) {
<mat-error>
Remarks are mandatory when
{{ isKycVerified ? 'updating a verified KYC record' : 'overriding the risk category' }}
</mat-error>
}
</mat-form-field>
}
</div>
@@ -1393,6 +1634,39 @@
<mat-option value="REGULATORY">Regulatory Authority</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field" (click)="freezeDatePicker.open()">
<mat-label>Freeze Date</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>calendar_today</mat-icon></span>
<input matInput required [max]="today" [matDatepicker]="freezeDatePicker" formControlName="freezeDate" />
<mat-datepicker-toggle matSuffix [for]="freezeDatePicker"></mat-datepicker-toggle>
<mat-datepicker #freezeDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Freeze Amount</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>currency_rupee</mat-icon></span>
<input matInput type="number" min="0" formControlName="freezeAmount" />
<mat-hint>Leave blank for a full freeze</mat-hint>
</mat-form-field>
}
@if (kycForm.get('accountStatus')?.value === 'UNFREEZE') {
<mat-form-field appearance="outline" class="form-field" (click)="unfreezeDatePicker.open()">
<mat-label>Unfreeze Date</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>lock_open</mat-icon></span>
<input matInput required [max]="today" [matDatepicker]="unfreezeDatePicker" formControlName="unfreezeDate" />
<mat-datepicker-toggle matSuffix [for]="unfreezeDatePicker"></mat-datepicker-toggle>
<mat-datepicker #unfreezeDatePicker></mat-datepicker>
</mat-form-field>
}
@if (kycForm.get('accountStatus')?.value === 'NO_DEBIT') {
<mat-form-field appearance="outline" class="form-field" (click)="noDebitDatePicker.open()">
<mat-label>No Debit From Date</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>money_off</mat-icon></span>
<input matInput required [matDatepicker]="noDebitDatePicker" formControlName="noDebitDate" />
<mat-datepicker-toggle matSuffix [for]="noDebitDatePicker"></mat-datepicker-toggle>
<mat-datepicker #noDebitDatePicker></mat-datepicker>
</mat-form-field>
}
</div>
</div>
@@ -1545,4 +1819,34 @@
</div>
</div>
</div>
<!-- Form Validation Summary -->
@if (kycForm && kycForm.invalid) {
<div
class="kyc-validation-summary"
style="
margin-top: 24px;
padding: 16px;
background: #fff5f5;
border: 1px solid #fee2e2;
border-radius: 12px;
display: flex;
align-items: flex-start;
gap: 12px;
clear: both;
"
>
<mat-icon style="color: #ef4444; flex-shrink: 0">error_outline</mat-icon>
<div style="display: flex; flex-direction: column; gap: 4px">
<span style="font-size: 14px; font-weight: 600; color: #991b1b"
>Please complete/correct the following fields to proceed:</span
>
<ul style="margin: 4px 0 0 16px; padding: 0; font-size: 13px; color: #b91c1c; line-height: 1.5">
@for (label of invalidFieldLabels; track label) {
<li>{{ label }}</li>
}
</ul>
</div>
</div>
}
</div>
@@ -304,6 +304,206 @@
color: #059669;
border: 1px solid #a7f3d0;
}
// Not verified, but covered by a declaration — deliberately amber rather than green so it never
// reads as a passing check.
&.declared {
background: #fffbeb;
color: #b45309;
border: 1px solid #fcd34d;
}
}
// ── PAN declaration summary ────────────────────────────────────────────────
.pan-decl-card {
margin: -4px 0 16px;
padding: 12px 14px;
border: 1px solid #fcd34d;
border-radius: 8px;
background: #fffbeb;
}
.pan-decl-card__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.pan-decl-card__icon {
color: #b45309;
font-size: 18px;
width: 18px;
height: 18px;
}
.pan-decl-card__title {
font-size: 13px;
font-weight: 600;
color: #92400e;
}
.pan-decl-card__date {
margin-left: auto;
font-size: 11px;
color: #b45309;
}
.pan-decl-card__rows {
display: flex;
flex-direction: column;
gap: 4px;
}
.pan-decl-card__row {
display: grid;
grid-template-columns: 130px 1fr;
gap: 10px;
font-size: 12.5px;
.k {
color: #92400e;
font-weight: 500;
}
.v {
color: #451a03;
word-break: break-word;
&.strong {
font-weight: 600;
}
}
}
.pan-decl-card__note {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid #fde68a;
font-size: 11.5px;
line-height: 1.5;
color: #b45309;
}
.pan-decl-card__actions {
display: flex;
gap: 8px;
margin-top: 10px;
.danger {
color: #b91c1c;
}
}
// ── Aadhaar enrolment receipt ──────────────────────────────────────────────
// Blue rather than the declaration's amber: nothing has failed here, the customer simply does not
// hold the document yet.
.aad-rcpt-offer {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin: -4px 0 16px;
padding: 10px 14px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
font-size: 12.5px;
color: #1e40af;
mat-icon {
color: #2563eb;
font-size: 18px;
width: 18px;
height: 18px;
}
button {
margin-left: auto;
color: #1d4ed8 !important;
border-color: #bfdbfe !important;
}
}
.aad-rcpt-card {
margin: -4px 0 16px;
padding: 12px 14px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
}
.aad-rcpt-card__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.aad-rcpt-card__icon {
color: #2563eb;
font-size: 18px;
width: 18px;
height: 18px;
}
.aad-rcpt-card__title {
font-size: 13px;
font-weight: 600;
color: #1e3a8a;
}
.aad-rcpt-card__date {
margin-left: auto;
font-size: 11px;
color: #2563eb;
}
.aad-rcpt-card__rows {
display: flex;
flex-direction: column;
gap: 4px;
}
.aad-rcpt-card__row {
display: grid;
grid-template-columns: 130px 1fr;
gap: 10px;
font-size: 12.5px;
.k {
color: #1e40af;
font-weight: 500;
}
.v {
color: #0f172a;
word-break: break-word;
&.mono {
font-family: monospace;
letter-spacing: 0.02em;
}
}
}
.aad-rcpt-card__note {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid #bfdbfe;
font-size: 11.5px;
line-height: 1.5;
color: #2563eb;
}
.aad-rcpt-card__actions {
display: flex;
gap: 8px;
margin-top: 10px;
.danger {
color: #b91c1c;
}
}
.verify-icon-ok {
@@ -2431,3 +2631,358 @@
}
}
}
/* The name and DOB that PAN / Aadhaar are matched against, surfaced from the General tab so a
mismatch is visible before the check runs rather than only after it fails. */
.identity-basis {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 11px 14px;
margin-bottom: 4px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
}
.identity-basis--incomplete {
border-color: #fdba74;
background: #fff7ed;
}
.identity-basis__icon {
flex: 0 0 auto;
font-size: 20px;
width: 20px;
height: 20px;
color: #1d4ed8;
.identity-basis--incomplete & {
color: #c2410c;
}
}
.identity-basis__body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.identity-basis__legend {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.identity-basis__pairs {
display: flex;
flex-wrap: wrap;
gap: 6px 24px;
}
.identity-basis__pair {
display: flex;
align-items: baseline;
gap: 7px;
}
.identity-basis__k {
font-size: 11px;
font-weight: 600;
color: #64748b;
}
.identity-basis__v {
font-size: 13.5px;
font-weight: 600;
color: #1e293b;
}
.identity-basis__hint {
font-size: 11.5px;
color: #64748b;
line-height: 1.45;
}
.identity-basis__warn {
font-size: 11.5px;
font-weight: 600;
color: #c2410c;
line-height: 1.45;
}
/* ═══════════════════════════════════════════════════════════════
PAN ↔ Aadhaar premium status card
─── Replaces the legacy table-row treatment. Dark gradient with
offset glow circles, large gradient icon, and a state-driven
accent (green / red / blue / amber).
═══════════════════════════════════════════════════════════════ */
.pal-card {
margin-top: 16px;
width: 100%;
min-height: 88px;
padding: 14px 18px 30px;
border-radius: 18px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #111827, #0f172a);
border: 1px solid rgb(255 255 255 / 8%);
box-shadow:
0 12px 32px rgb(0 0 0 / 35%),
inset 0 1px 1px rgb(255 255 255 / 5%);
font-family: Inter, 'Segoe UI', Roboto, sans-serif;
/* Decorative glows — colors swap with the state variant */
&::before {
content: '';
position: absolute;
width: 180px;
height: 180px;
background: #2563eb;
border-radius: 50%;
top: -110px;
right: -70px;
opacity: 0.18;
pointer-events: none;
}
&::after {
content: '';
position: absolute;
width: 120px;
height: 120px;
background: #22c55e;
border-radius: 50%;
bottom: -70px;
left: -50px;
opacity: 0.12;
pointer-events: none;
}
&__left {
display: flex;
align-items: center;
gap: 14px;
z-index: 2;
}
&__icon {
width: 54px;
height: 54px;
border-radius: 16px;
background: linear-gradient(145deg, #2563eb, #06b6d4);
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 22px;
position: relative;
box-shadow: 0 10px 20px rgb(37 99 235 / 35%);
flex-shrink: 0;
mat-icon {
font-size: 26px;
width: 26px;
height: 26px;
}
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: 16px;
border: 1px solid rgb(255 255 255 / 20%);
}
}
&__txt {
display: flex;
flex-direction: column;
gap: 4px;
}
&__small {
font-size: 10.5px;
color: #94a3b8;
letter-spacing: 0.1em;
text-transform: uppercase;
font-weight: 700;
}
&__title {
color: #fff;
font-size: 17px;
font-weight: 700;
line-height: 1.1;
}
&__status {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 4px;
padding: 4px 11px;
border-radius: 50px;
background: rgb(34 197 94 / 12%);
border: 1px solid rgb(74 222 128 / 25%);
color: #4ade80;
font-size: 11.5px;
font-weight: 600;
backdrop-filter: blur(5px);
width: fit-content;
mat-icon {
font-size: 13px;
width: 13px;
height: 13px;
}
}
&__badge {
z-index: 2;
width: 42px;
height: 42px;
border-radius: 50%;
background: linear-gradient(135deg, #22c55e, #16a34a);
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
box-shadow: 0 0 18px rgb(34 197 94 / 45%);
flex-shrink: 0;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
&__hint {
position: absolute;
bottom: 8px;
right: 16px;
font-size: 10px;
color: rgb(255 255 255 / 35%);
font-style: italic;
letter-spacing: 0.02em;
z-index: 1;
}
/* ─── State variants ─── */
&--ok {
&::before {
background: #2563eb;
}
&::after {
background: #22c55e;
}
}
&--fail {
border-color: rgb(239 68 68 / 22%);
&::before {
background: #ef4444;
opacity: 0.22;
}
&::after {
background: #f87171;
opacity: 0.16;
}
.pal-card__status {
background: rgb(239 68 68 / 14%);
border-color: rgb(248 113 113 / 32%);
color: #fca5a5;
}
.pal-card__badge {
background: linear-gradient(135deg, #ef4444, #b91c1c);
box-shadow: 0 0 18px rgb(239 68 68 / 45%);
}
.pal-card__icon {
background: linear-gradient(145deg, #ef4444, #f97316);
box-shadow: 0 10px 20px rgb(239 68 68 / 35%);
}
}
&--info {
border-color: rgb(59 130 246 / 22%);
&::before {
background: #3b82f6;
opacity: 0.22;
}
&::after {
background: #60a5fa;
opacity: 0.14;
}
.pal-card__status {
background: rgb(59 130 246 / 14%);
border-color: rgb(96 165 250 / 32%);
color: #93c5fd;
}
.pal-card__badge {
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
box-shadow: 0 0 18px rgb(59 130 246 / 45%);
}
}
&--pending {
border-color: rgb(245 158 11 / 22%);
&::before {
background: #f59e0b;
opacity: 0.22;
}
&::after {
background: #fbbf24;
opacity: 0.14;
}
.pal-card__status {
background: rgb(245 158 11 / 14%);
border-color: rgb(251 191 36 / 32%);
color: #fbbf24;
}
.pal-card__badge {
background: linear-gradient(135deg, #f59e0b, #b45309);
box-shadow: 0 0 18px rgb(245 158 11 / 45%);
}
.pal-card__icon {
background: linear-gradient(145deg, #f59e0b, #d97706);
box-shadow: 0 10px 20px rgb(245 158 11 / 35%);
}
}
}
@media (width <= 540px) {
.pal-card {
flex-wrap: wrap;
gap: 10px;
padding-bottom: 36px;
&__hint {
position: static;
width: 100%;
text-align: right;
margin-top: 4px;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -68,6 +68,32 @@
</div>
}
<!--
The OVD captured at onboarding has gone stale, or is about to. Sits beside the exception
banner because it is the same kind of thing: something staff must act on before the account
drifts out of compliance.
-->
@if (showKycDocumentAlert) {
<div class="kyc-doc-alert" [class.kyc-doc-alert--critical]="kycDocumentAlertSeverity === 'critical'">
<mat-icon class="kyc-doc-alert__icon">{{
kycDocumentAlertSeverity === 'critical' ? 'error' : 'schedule'
}}</mat-icon>
<div class="kyc-doc-alert__body">
<strong class="kyc-doc-alert__title">{{ kycDocumentAlertTitle }}</strong>
<p class="kyc-doc-alert__text">{{ kycDocumentAlertMessage }}</p>
</div>
<button
mat-flat-button
color="primary"
type="button"
class="kyc-doc-alert__action"
(click)="toggleEditMode()"
>
Update Document
</button>
</div>
}
<!-- Verification Table -->
<div class="kv-table">
<div class="kv-table__header">
@@ -87,12 +113,21 @@
<span class="kv-table__col kv-table__col--status">
@if (kycData.panVerified) {
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (panDeclarationTaken) {
<!-- Deliberately not "Verified": the check never passed, paperwork covers it. -->
<span class="kv-status kv-status--declared"><mat-icon>assignment_turned_in</mat-icon> Declaration</span>
} @else {
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.panVerifiedOn ? formatDate(kycData.panVerifiedOn) : '—'
kycData.panVerified
? kycData.panVerifiedOn
? formatDate(kycData.panVerifiedOn)
: '—'
: panDeclarationTaken && kycData.panDeclarationTakenOn
? formatDate(kycData.panDeclarationTakenOn)
: '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
<button
@@ -108,9 +143,80 @@
Re-Verify
}
</button>
<!--
A PAN that has never passed is not always fixable by another attempt. Offer the
declaration here too, so an existing client sitting in the exception list can be
cleared without the officer having to run a verification they know will fail.
-->
@if (!kycData.panVerified && !panDeclarationTaken && kycData.panNumber) {
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="!kycId || saving"
(click)="takePanDeclaration()"
>
Declaration
</button>
}
</span>
</div>
<!--
PAN is unverified but a declaration is on file. Shown in full because it is the only
reason PAN carries no KYC exception — a reviewer has to see what was declared.
-->
@if (!kycData.panVerified && panDeclarationTaken) {
<div class="kv-decl">
<div class="kv-decl__head">
<mat-icon>assignment_turned_in</mat-icon>
<span class="kv-decl__title">PAN Declaration</span>
@if (!saving) {
<span class="kv-decl__actions">
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takePanDeclaration()">
Edit
</button>
<button
mat-stroked-button
type="button"
class="kv-action-btn danger"
(click)="removePanDeclaration()"
>
Remove
</button>
</span>
}
</div>
<div class="kv-decl__rows">
<div class="kv-decl__row">
<span class="k">Does not match</span>
<span class="v strong">{{ panDeclarationMismatchLabel }}</span>
</div>
@if (kycData.panDeclarationNameOnPan) {
<div class="kv-decl__row">
<span class="k">Name on PAN</span>
<span class="v">{{ kycData.panDeclarationNameOnPan }}</span>
</div>
}
@if (kycData.panDeclarationDobOnPan) {
<div class="kv-decl__row">
<span class="k">DOB on PAN</span>
<span class="v">{{ formatDate(kycData.panDeclarationDobOnPan) }}</span>
</div>
}
@if (kycData.panDeclarationRemarks) {
<div class="kv-decl__row">
<span class="k">Declaration file</span>
<span class="v">{{ kycData.panDeclarationRemarks }}</span>
</div>
}
</div>
<div class="kv-decl__note">
PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file.
</div>
</div>
}
<!-- Aadhaar Row -->
<div class="kv-table__row">
<span class="kv-table__col kv-table__col--doc">
@@ -120,12 +226,21 @@
<span class="kv-table__col kv-table__col--status">
@if (kycData.aadhaarVerified) {
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (aadhaarReceiptTaken) {
<!-- Deliberately not "Verified": no Aadhaar exists yet, the slip covers it. -->
<span class="kv-status kv-status--receipt"><mat-icon>receipt_long</mat-icon> Receipt</span>
} @else {
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.aadhaarVerifiedOn ? formatDate(kycData.aadhaarVerifiedOn) : '—'
kycData.aadhaarVerified
? kycData.aadhaarVerifiedOn
? formatDate(kycData.aadhaarVerifiedOn)
: '—'
: aadhaarReceiptTaken && kycData.aadhaarReceiptTakenOn
? formatDate(kycData.aadhaarReceiptTakenOn)
: '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
@@ -139,9 +254,142 @@
Re-Verify
</button>
}
<!--
No Aadhaar number means the OTP check has nothing to run against. Offer the enrolment
slip here so an existing client sitting in the exception list can be cleared.
-->
@if (!kycData.aadhaarVerified && !aadhaarReceiptTaken) {
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="!kycId || saving"
(click)="takeAadhaarReceipt()"
>
Receipt
</button>
}
</span>
</div>
<!--
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
only reason Aadhaar carries no KYC exception.
-->
@if (!kycData.aadhaarVerified && aadhaarReceiptTaken) {
<div class="kv-rcpt">
<div class="kv-rcpt__head">
<mat-icon>receipt_long</mat-icon>
<span class="kv-rcpt__title">Aadhaar Enrolment Receipt</span>
@if (!saving) {
<span class="kv-rcpt__actions">
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takeAadhaarReceipt()">
Edit
</button>
<button
mat-stroked-button
type="button"
class="kv-action-btn danger"
(click)="removeAadhaarReceipt()"
>
Remove
</button>
</span>
}
</div>
<div class="kv-rcpt__rows">
<div class="kv-rcpt__row">
<span class="k">Enrolment ID</span>
<span class="v mono">{{ kycData.aadhaarEnrolmentId || '—' }}</span>
</div>
@if (kycData.aadhaarEnrolmentDate) {
<div class="kv-rcpt__row">
<span class="k">Enrolment date</span>
<span class="v">{{ formatDate(kycData.aadhaarEnrolmentDate) }}</span>
</div>
}
@if (kycData.aadhaarReceiptRemarks) {
<div class="kv-rcpt__row">
<span class="k">Remarks</span>
<span class="v">{{ kycData.aadhaarReceiptRemarks }}</span>
</div>
}
</div>
<div class="kv-rcpt__note">
Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file.
</div>
</div>
}
<!--
The OVD collected at onboarding. It has never appeared here, so the dates driving the
renewal alert were invisible — an officer told to update the document could not see which
one, nor when it was issued.
-->
@if (kycData.kycDocumentName) {
<div class="kv-table__row">
<span class="kv-table__col kv-table__col--doc">
<mat-icon class="kv-doc-icon">description</mat-icon> {{ kycData.kycDocumentName }}
</span>
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.kycDocumentNumber || '—' }}</span>
<span class="kv-table__col kv-table__col--status">
@switch (kycDocumentRenewal.state) {
@case ('EXPIRED') {
<span class="kv-status kv-status--fail"><mat-icon>error</mat-icon> Expired</span>
}
@case ('EXPIRING_SOON') {
<span class="kv-status kv-status--declared"><mat-icon>schedule</mat-icon> Expires Soon</span>
}
@case ('DUE') {
<span class="kv-status kv-status--declared"><mat-icon>update</mat-icon> Update Due</span>
}
@case ('OK') {
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Current</span>
}
@default {
<span class="kv-status kv-status--pending"><mat-icon>help_outline</mat-icon> No dates</span>
}
}
</span>
<!--
Issued-on, not verified-on: this document is sighted, never API-verified.
Every document shows an expiry, printed or not: one with no expiry on the card is
still only good for five years from issue, so the derived date is shown rather than a
blank. It is tagged so a calculated date is never mistaken for one on the document.
-->
<span class="kv-table__col kv-table__col--date">
@if (kycData.kycDocIssueDate || kycData.kycDocExpiryDate) {
<span class="kv-doc-dates">
@if (kycData.kycDocIssueDate) {
<span>Issued {{ formatDate(kycData.kycDocIssueDate) }}</span>
}
@if (kycDocumentExpiresOn) {
<span
class="kv-doc-dates__expiry"
[class.kv-doc-dates__expiry--derived]="kycDocumentExpiryIsDerived"
>
Expires {{ kycDocumentExpiresOn }}
@if (kycDocumentExpiryIsDerived) {
<span
class="kv-doc-dates__tag"
matTooltip="No expiry printed on this document — calculated as 5 years from the issue date"
>5-yr</span
>
}
</span>
}
</span>
} @else {
—
}
</span>
<span class="kv-table__col kv-table__col--action">
<button mat-stroked-button class="kv-action-btn" type="button" (click)="toggleEditMode()">Update</button>
</span>
</div>
}
<!-- GSTIN Row (Non-Individual only) -->
@if (isNonIndividual) {
<div class="kv-table__row">
@@ -360,39 +608,72 @@
<!-- KYC Meta Grid -->
<div class="kv-meta-grid">
<div class="kv-meta-item">
<span class="kv-meta-label">KYC Type</span>
<span class="kv-meta-value">{{
kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—'
}}</span>
<mat-icon class="kv-meta-icon icon-blue">assignment_ind</mat-icon>
<div class="kv-meta-info">
<span class="kv-meta-label">KYC Type</span>
<span class="kv-meta-value">
{{ kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—' }}
</span>
</div>
</div>
<div class="kv-meta-item">
<span class="kv-meta-label">Risk Category</span>
<span class="kv-meta-value">{{
kycData.riskCategory === 1
? 'Low'
: kycData.riskCategory === 2
? 'Medium'
: kycData.riskCategory === 3
? 'High'
: '—'
}}</span>
<mat-icon
class="kv-meta-icon"
[ngClass]="{
'icon-green': kycData.riskCategory === 1,
'icon-orange': kycData.riskCategory === 2,
'icon-red': kycData.riskCategory === 3
}"
>shield</mat-icon
>
<div class="kv-meta-info">
<span class="kv-meta-label">Risk Category</span>
<span class="kv-meta-value">
{{
kycData.riskCategory === 1
? 'Low'
: kycData.riskCategory === 2
? 'Medium'
: kycData.riskCategory === 3
? 'High'
: '—'
}}
</span>
</div>
</div>
<div class="kv-meta-item">
<span class="kv-meta-label">Initiated On</span>
<span class="kv-meta-value">{{ formatDate(kycData.initiatedOn) }}</span>
<mat-icon class="kv-meta-icon icon-grey">calendar_today</mat-icon>
<div class="kv-meta-info">
<span class="kv-meta-label">Initiated On</span>
<span class="kv-meta-value">{{ formatDate(kycData.initiatedOn) }}</span>
</div>
</div>
<div class="kv-meta-item">
<span class="kv-meta-label">Last Verified</span>
<span class="kv-meta-value">{{ formatDate(kycData.verifiedOn) }}</span>
<mat-icon class="kv-meta-icon icon-green">check_circle</mat-icon>
<div class="kv-meta-info">
<span class="kv-meta-label">Last Verified</span>
<span class="kv-meta-value">{{ formatDate(kycData.verifiedOn) }}</span>
</div>
</div>
<div class="kv-meta-item">
<span class="kv-meta-label">Expiry Date</span>
<span class="kv-meta-value">{{ formatDate(kycData.expiryDate) }}</span>
<mat-icon class="kv-meta-icon icon-red">history_toggle_off</mat-icon>
<div class="kv-meta-info">
<span class="kv-meta-label">Expiry Date</span>
<span class="kv-meta-value">{{ formatDate(kycData.expiryDate) }}</span>
</div>
</div>
@if (kycData.remarks) {
<div class="kv-meta-item kv-meta-item--full">
<span class="kv-meta-label">Remarks</span>
<span class="kv-meta-value">{{ kycData.remarks }}</span>
<mat-icon class="kv-meta-icon icon-grey">notes</mat-icon>
<div class="kv-meta-info">
<span class="kv-meta-label">Remarks</span>
<span class="kv-meta-value">{{ kycData.remarks }}</span>
</div>
</div>
}
</div>
@@ -567,6 +848,22 @@
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
<span>{{ verificationError.pan }}</span>
</div>
<!--
The check can fail for reasons no retry will fix — a PAN card that spells the
name differently, or a vendor that is simply down. The declaration is the way
out, so it is offered here rather than leaving the officer at a dead end.
-->
<div class="kyc-decl-offer">
<span>Customer's PAN card genuinely carries different details, or PAN cannot be checked?</span>
<button
mat-stroked-button
type="button"
[disabled]="!kycId || saving"
(click)="takePanDeclaration()"
>
{{ panDeclarationTaken ? 'Edit Declaration' : 'Take Declaration' }}
</button>
</div>
}
@if (verificationStatus.pan === 'VERIFIED' && verificationDetails.pan?.name) {
<span class="verification-detail-text">{{ verificationDetails.pan.name }}</span>
@@ -678,6 +975,22 @@
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
<span>{{ verificationError.aadhaar }}</span>
</div>
<!--
A failed OTP is not always something a retry can fix — a customer who has only
applied for Aadhaar has no number to verify at all. The enrolment slip is the way
out, so it is offered here rather than leaving the officer at a dead end.
-->
<div class="kyc-decl-offer">
<span>Customer has applied for Aadhaar but not received it yet?</span>
<button
mat-stroked-button
type="button"
[disabled]="!kycId || saving"
(click)="takeAadhaarReceipt()"
>
{{ aadhaarReceiptTaken ? 'Edit Receipt' : 'Record Receipt' }}
</button>
</div>
}
</div>
@@ -559,6 +559,19 @@
background: #eff6ff;
color: #2563eb;
}
// Unverified, covered by a declaration — amber, never green, so it cannot read as a pass.
&--declared {
background: #fffbeb;
color: #b45309;
}
// Covered by an enrolment slip. Blue rather than amber: nothing failed, the customer simply
// does not hold the document yet.
&--receipt {
background: #eff6ff;
color: #2563eb;
}
}
.kv-action-btn {
@@ -570,6 +583,265 @@
font-weight: 600 !important;
color: #1074b9 !important;
border-color: #1074b9 !important;
&.danger {
color: #b91c1c !important;
border-color: #fca5a5 !important;
}
}
/* PAN declaration detail, shown under the PAN row when the check did not pass */
.kv-decl {
margin: 4px 0 8px;
padding: 12px 14px;
border: 1px solid #fcd34d;
border-radius: 8px;
background: #fffbeb;
}
.kv-decl__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
mat-icon {
color: #b45309;
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
}
}
.kv-decl__title {
font-size: 13px;
font-weight: 600;
color: #92400e;
}
.kv-decl__actions {
margin-left: auto;
display: flex;
gap: 8px;
}
.kv-decl__rows {
display: flex;
flex-direction: column;
gap: 4px;
}
.kv-decl__row {
display: grid;
grid-template-columns: 140px 1fr;
gap: 10px;
font-size: 12.5px;
.k {
color: #92400e;
font-weight: 500;
}
.v {
color: #451a03;
word-break: break-word;
&.strong {
font-weight: 600;
}
}
}
.kv-decl__note {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid #fde68a;
font-size: 11.5px;
line-height: 1.5;
color: #b45309;
}
/* KYC document renewal alert — amber for a stale document, red once it has actually expired */
.kyc-doc-alert {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
margin-bottom: 20px;
border: 1px solid #f59e0b;
border-radius: 8px;
background: #fef3c7;
&--critical {
border-color: #ef4444;
background: #fee2e2;
}
}
.kyc-doc-alert__icon {
flex: 0 0 auto;
margin-top: 2px;
color: #d97706;
.kyc-doc-alert--critical & {
color: #dc2626;
}
}
.kyc-doc-alert__body {
flex: 1 1 auto;
min-width: 0;
}
.kyc-doc-alert__title {
font-size: 14px;
color: #92400e;
.kyc-doc-alert--critical & {
color: #991b1b;
}
}
.kyc-doc-alert__text {
margin: 4px 0 0;
font-size: 13px;
line-height: 1.5;
color: #b45309;
.kyc-doc-alert--critical & {
color: #b91c1c;
}
}
.kyc-doc-alert__action {
flex: 0 0 auto;
align-self: center;
}
/* Issue / expiry pair in the document row */
.kv-doc-dates {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
}
.kv-doc-dates__expiry {
display: inline-flex;
align-items: center;
gap: 6px;
color: #64748b;
/* Calculated from the issue date rather than printed on the document. */
&--derived {
color: #94a3b8;
}
}
.kv-doc-dates__tag {
padding: 0 5px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
font-size: 9.5px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #94a3b8;
cursor: default;
}
/* Aadhaar enrolment receipt detail, shown under the Aadhaar row when the check did not pass */
.kv-rcpt {
margin: 4px 0 8px;
padding: 12px 14px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
}
.kv-rcpt__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
mat-icon {
color: #2563eb;
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
}
}
.kv-rcpt__title {
font-size: 13px;
font-weight: 600;
color: #1e3a8a;
}
.kv-rcpt__actions {
margin-left: auto;
display: flex;
gap: 8px;
}
.kv-rcpt__rows {
display: flex;
flex-direction: column;
gap: 4px;
}
.kv-rcpt__row {
display: grid;
grid-template-columns: 140px 1fr;
gap: 10px;
font-size: 12.5px;
.k {
color: #1e40af;
font-weight: 500;
}
.v {
color: #0f172a;
word-break: break-word;
&.mono {
font-family: monospace;
letter-spacing: 0.02em;
}
}
}
.kv-rcpt__note {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid #bfdbfe;
font-size: 11.5px;
line-height: 1.5;
color: #2563eb;
}
/* Offer shown beneath a failed PAN verification in edit mode */
.kyc-decl-offer {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 8px;
padding: 10px 12px;
border: 1px solid #fde68a;
border-radius: 6px;
background: #fffbeb;
font-size: 12.5px;
color: #92400e;
button {
margin-left: auto;
color: #b45309 !important;
border-color: #fcd34d !important;
}
}
/* OTP Bar (below table) */
@@ -622,27 +894,76 @@
.kv-meta-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
margin-top: 16px;
border-radius: 12px;
padding: 16px 20px;
gap: 20px;
margin-top: 20px;
box-shadow:
0 1px 3px rgb(0 0 0 / 2%),
0 4px 6px -1px rgb(0 0 0 / 5%);
}
.kv-meta-item {
display: flex;
flex-direction: column;
gap: 3px;
padding: 12px 14px;
border-right: 1px solid #f1f5f9;
border-bottom: 1px solid #f1f5f9;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: 8px;
transition: all 0.2s ease;
&:nth-child(5n) {
border-right: none;
&:hover {
background: #f8fafc;
}
&--full {
grid-column: 1 / -1;
border-right: none;
border-top: 1px solid #f1f5f9;
padding-top: 16px;
margin-top: 4px;
border-radius: 0;
align-items: flex-start;
&:hover {
background: transparent;
}
}
}
.kv-meta-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.kv-meta-icon {
font-size: 24px !important;
width: 24px !important;
height: 24px !important;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.icon-blue {
color: #3b82f6;
}
&.icon-green {
color: #10b981;
}
&.icon-orange {
color: #f59e0b;
}
&.icon-red {
color: #ef4444;
}
&.icon-grey {
color: #64748b;
}
}
@@ -656,7 +977,7 @@
.kv-meta-value {
font-size: 13px;
font-weight: 500;
font-weight: 600;
color: #1e293b;
}
@@ -677,14 +998,7 @@
.kv-meta-grid {
grid-template-columns: repeat(3, 1fr);
}
.kv-meta-item:nth-child(5n) {
border-right: 1px solid #f1f5f9;
}
.kv-meta-item:nth-child(3n) {
border-right: none;
gap: 16px;
}
}
@@ -10,11 +10,26 @@
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { catchError, of, Subject, takeUntil } from 'rxjs';
/** Custom Services */
import { ClientsService } from '../../clients.service';
import { deriveKycExceptionFields } from '../../kyc-exception.util';
import {
deriveKycDocumentRenewal,
kycDocumentNeedsAttention,
KycDocumentRenewalStatus,
KYC_DOCUMENT_RENEWAL_YEARS
} from '../../kyc-document-renewal.util';
import {
PanDeclarationDialogComponent,
PanDeclarationResult
} from '../../shared/pan-declaration-dialog/pan-declaration-dialog.component';
import {
AadhaarReceiptDialogComponent,
AadhaarReceiptResult
} from '../../shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component';
import { Dates } from 'app/core/utils/dates';
import { jsPDF } from 'jspdf';
import { MatIcon } from '@angular/material/icon';
@@ -47,6 +62,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
private clientsService = inject(ClientsService);
private formBuilder = inject(UntypedFormBuilder);
private dateUtils = inject(Dates);
private dialog = inject(MatDialog);
private destroy$ = new Subject<void>();
@@ -200,6 +216,127 @@ export class KycTabComponent implements OnInit, OnDestroy {
}
}
/** True when the stored record carries a signed declaration standing in for the PAN check. */
get panDeclarationTaken(): boolean {
return this.kycData?.panDeclarationTaken === true;
}
/** "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;
if (!fields || fields === 'NONE') {
return 'PAN could not be verified';
}
return fields
.split(',')
.map((f) => (f === 'NAME' ? 'Name' : 'Date of Birth'))
.join(' and ');
}
/** True when the stored record carries a UIDAI enrolment slip standing in for the OTP check. */
get aadhaarReceiptTaken(): boolean {
return this.kycData?.aadhaarReceiptTaken === true;
}
// ── KYC document renewal ──────────────────────────────────────────────────
/**
* Whether the OVD captured at onboarding is still current — see `deriveKycDocumentRenewal()`.
*
* The issue and expiry dates have always been captured on the KYC step but never read back, so
* a client onboarded on a ten-year-old licence looked identical to one onboarded yesterday.
*/
get kycDocumentRenewal(): KycDocumentRenewalStatus {
return deriveKycDocumentRenewal({
issueDate: this.parseApiDate(this.kycData?.kycDocIssueDate),
expiryDate: this.parseApiDate(this.kycData?.kycDocExpiryDate),
today: new Date()
});
}
/** Only raise the banner for a document that actually exists and actually needs action. */
get showKycDocumentAlert(): boolean {
return !!this.kycData?.kycDocumentName && kycDocumentNeedsAttention(this.kycDocumentRenewal.state);
}
/** Heading for the alert — the three states call for visibly different things. */
get kycDocumentAlertTitle(): string {
switch (this.kycDocumentRenewal.state) {
case 'EXPIRED':
return 'KYC Document Expired';
case 'EXPIRING_SOON':
return 'KYC Document Expires Soon';
default:
return 'KYC Document Due for Update';
}
}
/**
* The sentence staff act on. Each case names the date that triggered it, because "update the
* document" without saying why leaves the officer checking the form to work out which clock ran
* out.
*/
get kycDocumentAlertMessage(): string {
const status = this.kycDocumentRenewal;
const docName = this.kycData?.kycDocumentName || 'The KYC document';
// `formatDate()` only understands Fineract's [y, m, d] arrays; these are real Date objects.
const dueOn = status.dueOn ? this.dateUtils.formatDate(status.dueOn, 'dd MMMM yyyy') : '';
const days = status.daysRemaining ?? 0;
switch (status.state) {
case 'EXPIRED':
return `${docName} expired on ${dueOn} and is no longer a valid officially valid document. Collect a renewed document from the customer and update the KYC record.`;
case 'EXPIRING_SOON':
return days === 0
? `${docName} expires today (${dueOn}). Ask the customer for the renewed document before it lapses.`
: `${docName} expires on ${dueOn} — ${days} day${days === 1 ? '' : 's'} from now. Ask the customer for the renewed document before it lapses.`;
default:
return `${docName} was issued more than ${KYC_DOCUMENT_RENEWAL_YEARS} years ago and fell due for update on ${dueOn}. Re-collect the document from the customer and update the KYC record.`;
}
}
/** Drives the banner's colour — an expired document is a harder failure than a stale one. */
get kycDocumentAlertSeverity(): 'critical' | 'warning' {
return this.kycDocumentRenewal.state === 'EXPIRED' ? 'critical' : 'warning';
}
/**
* The date the document stops counting as current, whether or not one is printed on it.
*
* A Voter ID or NREGA card carries no expiry, but it is not valid forever for KYC purposes —
* the five-year rule is its expiry. Showing the derived date rather than a blank means every
* document answers "valid until when?", and the officer sees the same date the alert will fire on
* instead of having to add five years in their head.
*/
get kycDocumentExpiresOn(): string {
const dueOn = this.kycDocumentRenewal.dueOn;
return dueOn ? this.dateUtils.formatDate(dueOn, 'dd MMMM yyyy') : '';
}
/**
* True when the date above was calculated from the issue date rather than read off the document.
* Flagged in the UI so a derived date is never mistaken for one printed on the card.
*/
get kycDocumentExpiryIsDerived(): boolean {
return this.kycDocumentRenewal.reason === 'AGE';
}
/** Compact chip label for the document row in the verification table. */
get kycDocumentStatusLabel(): string {
switch (this.kycDocumentRenewal.state) {
case 'EXPIRED':
return 'Expired';
case 'EXPIRING_SOON':
return 'Expires Soon';
case 'DUE':
return 'Update Due';
case 'OK':
return 'Current';
default:
return '—';
}
}
/**
* Exception columns derived from verification status — see `deriveKycExceptionFields()`.
*
@@ -213,7 +350,11 @@ export class KycTabComponent implements OnInit, OnDestroy {
return deriveKycExceptionFields({
panVerified,
// A signed declaration closes the PAN item by paperwork when the API never can.
panDeclarationTaken: this.panDeclarationTaken,
aadhaarVerified,
// An enrolment slip closes the Aadhaar item when there is no number to verify yet.
aadhaarReceiptTaken: this.aadhaarReceiptTaken,
aadhaarApplicable: !this.isNonIndividual,
hadException: this.kycData?.isException === true,
previousReason: this.kycData?.exceptionReason || null,
@@ -326,7 +467,12 @@ export class KycTabComponent implements OnInit, OnDestroy {
if (response?.changes?.panVerified) {
this.verificationStatus.pan = 'VERIFIED';
this.verificationDetails.pan = response.changes;
this.loadKycData();
// A PAN that now verifies makes any earlier declaration moot.
if (this.panDeclarationTaken) {
this.writePanDeclaration(null);
} else {
this.loadKycData();
}
} else {
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan = 'PAN verification failed.';
@@ -345,6 +491,283 @@ export class KycTabComponent implements OnInit, OnDestroy {
}, 'pan');
}
/**
* Records the customer declaration that stands in for a PAN check the client cannot pass.
*
* Offered for any PAN failure, not just a name/DOB mismatch — an officer facing an unreachable
* vendor is just as stuck as one facing a misspelt PAN card, and the same signed paperwork
* resolves both. When the failure was not a comparable mismatch there is no PAN card to compare
* against, so the dialog only asks why the declaration is being taken.
*/
takePanDeclaration(): void {
if (!this.kycId) {
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
return;
}
const failureMessage = this.verificationError.pan || 'PAN has not been verified.';
const mismatch = failureMessage.toLowerCase().includes('does not match');
this.dialog
.open(PanDeclarationDialogComponent, {
width: '720px',
maxWidth: '95vw',
disableClose: true,
data: {
submittedName: this.kycData?.clientDisplayName || this.clientData?.displayName || '',
submittedDob: this.clientDateOfBirth || '',
panNumber: this.kycForm.value.panNumber || this.kycData?.panNumber || '',
failureMessage,
// Only a reported name/DOB mismatch gives the officer something to compare against.
unverifiable: !mismatch,
existing: this.panDeclarationTaken
? {
mismatchFields: this.kycData?.panDeclarationMismatchFields || 'NONE',
nameOnPan: this.kycData?.panDeclarationNameOnPan || null,
dobOnPan: this.parseApiDate(this.kycData?.panDeclarationDobOnPan),
// The scan itself lives with the client's documents; the record keeps its name.
file: null,
fileName: this.kycData?.panDeclarationRemarks || ''
}
: null
}
})
.afterClosed()
.subscribe((result: PanDeclarationResult | undefined) => {
if (!result) {
return;
}
// The scan has to be filed against the client before the record says a declaration exists —
// otherwise the PAN exception is waived by a document nobody can produce.
if (result.file) {
this.uploadPanDeclarationFile(result.file, () => this.writePanDeclaration(result));
} else {
this.writePanDeclaration(result);
}
});
}
/** Files the signed declaration under the client's documents, then continues with `onDone`. */
private uploadPanDeclarationFile(file: File, onDone: () => void): void {
this.saving = true;
this.errorMessage = null;
const formData = new FormData();
formData.append('file', file);
formData.append('name', 'PAN Declaration');
formData.append(
'description',
`Signed PAN declaration for ${this.kycForm.value.panNumber || this.kycData?.panNumber || 'PAN'}`
);
this.clientsService
.uploadClientDocument(this.clientId, formData)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.saving = false;
onDone();
},
error: (err: any) => {
this.saving = false;
this.errorMessage =
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
err?.message ||
'Could not upload the signed declaration. Please try again.';
}
});
}
/** Officer-initiated removal — the PAN exception is raised again on this same save. */
removePanDeclaration(): void {
if (this.panDeclarationTaken) {
this.writePanDeclaration(null);
}
}
/**
* Persists (or clears) the declaration on the KYC record.
*
* `buildExceptionFields()` reads the declaration off `kycData`, so that has to be updated before
* the payload is built — otherwise the save would carry the declaration and the PAN exception at
* the same time, which is exactly the contradiction the declaration exists to prevent.
*/
private writePanDeclaration(declaration: PanDeclarationResult | null): void {
this.saving = true;
this.errorMessage = null;
const previous = {
panDeclarationTaken: this.kycData?.panDeclarationTaken,
panDeclarationMismatchFields: this.kycData?.panDeclarationMismatchFields,
panDeclarationNameOnPan: this.kycData?.panDeclarationNameOnPan,
panDeclarationDobOnPan: this.kycData?.panDeclarationDobOnPan,
panDeclarationRemarks: this.kycData?.panDeclarationRemarks,
panDeclarationTakenOn: this.kycData?.panDeclarationTakenOn
};
const dateFormat = 'dd MMMM yyyy';
const declarationFields = {
panDeclarationTaken: !!declaration,
panDeclarationMismatchFields: declaration?.mismatchFields || null,
panDeclarationNameOnPan: declaration?.nameOnPan || null,
panDeclarationDobOnPan: declaration?.dobOnPan
? this.dateUtils.formatDate(declaration.dobOnPan, dateFormat)
: null,
// Names the uploaded declaration filed under the client's documents.
panDeclarationRemarks: declaration?.fileName || null,
panDeclarationTakenOn: declaration ? this.dateUtils.formatDate(new Date(), dateFormat) : null
};
this.kycData = { ...(this.kycData || {}), ...declarationFields };
const payload: any = {
locale: 'en',
dateFormat,
riskCategory: this.kycForm.value.riskCategory || 1,
...declarationFields,
...this.buildExceptionFields()
};
this.clientsService
.updateClientKyc(this.clientId, this.kycId, payload)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.saving = false;
this.loadKycData();
},
error: (err: any) => {
this.saving = false;
// Put the record back so the tab never shows a declaration the server did not accept.
this.kycData = { ...(this.kycData || {}), ...previous };
this.errorMessage =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to save the PAN declaration.';
}
});
}
/**
* Records the UIDAI enrolment receipt accepted in place of a verified Aadhaar.
*
* A customer who has applied for Aadhaar but has not been issued one has no number to put through
* the OTP flow, so the check can never pass. The slip closes the item instead.
*/
takeAadhaarReceipt(): void {
if (!this.kycId) {
this.verificationError.aadhaar = 'KYC record not found. Save KYC details first.';
return;
}
this.dialog
.open(AadhaarReceiptDialogComponent, {
width: '560px',
maxWidth: '95vw',
disableClose: true,
data: {
clientName: this.kycData?.clientDisplayName || this.clientData?.displayName || '',
reason:
this.verificationError.aadhaar ||
'Aadhaar has not been verified. Record the enrolment slip if the customer has applied but not yet received their Aadhaar.',
existing: this.aadhaarReceiptTaken
? {
enrolmentId: this.kycData?.aadhaarEnrolmentId || '',
enrolmentDate: this.parseApiDate(this.kycData?.aadhaarEnrolmentDate),
remarks: this.kycData?.aadhaarReceiptRemarks || ''
}
: null
}
})
.afterClosed()
.subscribe((result: AadhaarReceiptResult | undefined) => {
if (result) {
this.writeAadhaarReceipt(result);
}
});
}
/** Officer-initiated removal — the Aadhaar exception is raised again on this same save. */
removeAadhaarReceipt(): void {
if (this.aadhaarReceiptTaken) {
this.writeAadhaarReceipt(null);
}
}
/**
* Persists (or clears) the enrolment receipt on the KYC record.
*
* `buildExceptionFields()` reads the receipt off `kycData`, so that has to be updated before the
* payload is built — otherwise the save would carry the receipt and the Aadhaar exception at the
* same time, the contradiction the receipt exists to prevent.
*/
private writeAadhaarReceipt(receipt: AadhaarReceiptResult | null): void {
this.saving = true;
this.errorMessage = null;
const previous = {
aadhaarReceiptTaken: this.kycData?.aadhaarReceiptTaken,
aadhaarEnrolmentId: this.kycData?.aadhaarEnrolmentId,
aadhaarEnrolmentDate: this.kycData?.aadhaarEnrolmentDate,
aadhaarReceiptRemarks: this.kycData?.aadhaarReceiptRemarks,
aadhaarReceiptTakenOn: this.kycData?.aadhaarReceiptTakenOn
};
const dateFormat = 'dd MMMM yyyy';
const receiptFields = {
aadhaarReceiptTaken: !!receipt,
aadhaarEnrolmentId: receipt?.enrolmentId || null,
aadhaarEnrolmentDate: receipt?.enrolmentDate
? this.dateUtils.formatDate(receipt.enrolmentDate, dateFormat)
: null,
aadhaarReceiptRemarks: receipt?.remarks || null,
aadhaarReceiptTakenOn: receipt ? this.dateUtils.formatDate(new Date(), dateFormat) : null
};
this.kycData = { ...(this.kycData || {}), ...receiptFields };
const payload: any = {
locale: 'en',
dateFormat,
riskCategory: this.kycForm.value.riskCategory || 1,
...receiptFields,
...this.buildExceptionFields()
};
this.clientsService
.updateClientKyc(this.clientId, this.kycId, payload)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.saving = false;
this.verificationError.aadhaar = null;
this.loadKycData();
},
error: (err: any) => {
this.saving = false;
// Put the record back so the tab never shows a receipt the server did not accept.
this.kycData = { ...(this.kycData || {}), ...previous };
this.errorMessage =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to save the Aadhaar enrolment receipt.';
}
});
}
/** Fineract serialises dates as `[yyyy, M, d]`; the same field can also arrive as a string. */
private parseApiDate(raw: any): Date | null {
if (!raw) {
return null;
}
if (Array.isArray(raw) && raw.length >= 3) {
return new Date(raw[0], raw[1] - 1, raw[2]);
}
const parsed = new Date(raw);
return isNaN(parsed.getTime()) ? null : parsed;
}
/** Show Aadhaar input in summary view for re-verification */
startAadhaarReVerify(): void {
this.showAadhaarReVerify = true;
@@ -416,7 +839,13 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.verificationStatus.aadhaar = 'VERIFIED';
this.verificationDetails.aadhaar = response.changes;
this.resetOtpState();
this.loadKycData();
// Aadhaar has been issued and verified, so the enrolment slip has served its purpose —
// drop it rather than leave the record claiming the customer is still waiting.
if (this.aadhaarReceiptTaken) {
this.writeAadhaarReceipt(null);
} else {
this.loadKycData();
}
} else {
this.verificationStatus.aadhaar = 'FAILED';
this.verificationError.aadhaar = 'OTP verification failed.';
@@ -619,13 +1048,43 @@ export class KycTabComponent implements OnInit, OnDestroy {
// PAN
y = addLine('PAN Number:', kyc.panNumber || '—', y);
y = addLine('PAN Status:', kyc.panVerified ? 'Verified' : 'Pending', y);
// "Pending" would misdescribe a PAN that was checked, failed, and was settled by declaration —
// nothing is pending on that record, and the certificate has to say what actually happened.
y = addLine(
'PAN Status:',
kyc.panVerified ? 'Verified' : this.panDeclarationTaken ? 'Not Verified — Declaration Taken' : 'Pending',
y
);
if (kyc.panVerifiedOn) y = addLine('PAN Verified On:', this.formatDate(kyc.panVerifiedOn), y);
if (!kyc.panVerified && this.panDeclarationTaken) {
y = addLine('Declared Mismatch:', this.panDeclarationMismatchLabel, y);
if (kyc.panDeclarationNameOnPan) y = addLine('Name on PAN:', kyc.panDeclarationNameOnPan, y);
if (kyc.panDeclarationDobOnPan) {
y = addLine('DOB on PAN:', this.formatDate(kyc.panDeclarationDobOnPan), y);
}
if (kyc.panDeclarationTakenOn) {
y = addLine('Declaration Taken On:', this.formatDate(kyc.panDeclarationTakenOn), y);
}
}
// Aadhaar
y = addLine('Aadhaar Number:', kyc.aadhaarNumberMasked || '—', y);
y = addLine('Aadhaar Status:', kyc.aadhaarVerified ? 'Verified' : 'Pending', y);
// As with PAN: "Pending" would misdescribe a client who holds only an enrolment slip.
y = addLine(
'Aadhaar Status:',
kyc.aadhaarVerified ? 'Verified' : this.aadhaarReceiptTaken ? 'Not Verified — Enrolment Receipt' : 'Pending',
y
);
if (kyc.aadhaarVerifiedOn) y = addLine('Aadhaar Verified On:', this.formatDate(kyc.aadhaarVerifiedOn), y);
if (!kyc.aadhaarVerified && this.aadhaarReceiptTaken) {
if (kyc.aadhaarEnrolmentId) y = addLine('Enrolment ID:', kyc.aadhaarEnrolmentId, y);
if (kyc.aadhaarEnrolmentDate) {
y = addLine('Enrolment Date:', this.formatDate(kyc.aadhaarEnrolmentDate), y);
}
if (kyc.aadhaarReceiptTakenOn) {
y = addLine('Receipt Recorded On:', this.formatDate(kyc.aadhaarReceiptTakenOn), y);
}
}
// PAN-Aadhaar Link
const seedingStatus =
-6
View File
@@ -98,12 +98,6 @@
</td>
</ng-container>
<!-- Client ID Column -->
<ng-container matColumnDef="accountNumber">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Client ID</th>
<td mat-cell *matCellDef="let row" class="cell-mono">{{ row.id }}</td>
</ng-container>
<!-- CIF Number Column -->
<ng-container matColumnDef="cifNumber">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.CIF Number' | translate }}</th>
-1
View File
@@ -88,7 +88,6 @@ export class ClientsComponent implements OnInit {
displayedColumns = [
'displayName',
'accountNumber',
'cifNumber',
'legalForm',
'status',
+9
View File
@@ -809,6 +809,15 @@ export class ClientsService {
return this.http.get<any[]>('/kyc/risk-rules');
}
/**
* Adds a rule to the AML catalog. Amount/window details in the payload also create the
* matching live-transaction rule server side; the response is the persisted rule in the same
* shape as {@link getClientRiskRules}.
*/
createClientRiskRule(rule: any): Observable<any> {
return this.http.post('/kyc/risk-rules', rule);
}
/** Fetch code values by code ID */
getCodeValuesById(codeId: number): Observable<any[]> {
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
@@ -75,6 +75,8 @@
[clientId]="createdClientId"
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
[verificationName]="clientGeneralStep?.verificationName || ''"
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
[readOnly]="isTabReadOnly(1)"
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
></mifosx-client-kyc-step>
@@ -223,6 +223,15 @@ export class CreateClientComponent {
// KYC tab — block when the kyc form has unfilled required fields
// (kycDocumentName / kycDocumentNumber / pattern errors on PAN / Aadhaar etc.)
const kf = this.clientKycStep?.kycForm;
if (kf && !kf.valid) {
const invalid = [];
for (const name in kf.controls) {
if (kf.controls[name].invalid) {
invalid.push({ name, errors: kf.controls[name].errors, value: kf.controls[name].value });
}
}
console.log('--- KYC Form is INVALID. Offending controls:', invalid);
}
return !kf || kf.valid;
}
if (this.legalFormType === 2) {
@@ -824,6 +833,9 @@ export class CreateClientComponent {
* Mirrors identities-tab.createIdentifierWithDocs.
*/
private saveAddressProof(clientId: string) {
if (this.clientIndiaAddressStep?.existingAddressProofIdentifierId) {
return;
}
const payload = this.clientIndiaAddressStep?.addressProofPayload;
if (!payload?.proofId) return;
@@ -839,6 +851,9 @@ export class CreateClientComponent {
next: (res: any) => {
const identifierId = res?.resourceId;
if (!identifierId) return;
if (this.clientIndiaAddressStep) {
this.clientIndiaAddressStep.existingAddressProofIdentifierId = identifierId.toString();
}
for (const entry of payload.files) {
const formData = new FormData();
formData.append('name', `${proofName} — ${entry.slot}`);
@@ -855,6 +870,9 @@ export class CreateClientComponent {
* + upload each selected file as a document attached to that identifier.
*/
private saveKycDocProof(clientId: string) {
if (this.clientKycStep?.existingKycIdentifierId) {
return;
}
const payload = this.clientKycStep?.kycDocPayload;
if (!payload?.proofId || !payload?.files?.length) return;
@@ -882,6 +900,9 @@ export class CreateClientComponent {
next: (res: any) => {
const identifierId = res?.resourceId;
if (!identifierId) return;
if (this.clientKycStep) {
this.clientKycStep.existingKycIdentifierId = identifierId.toString();
}
for (const entry of payload.files) {
const formData = new FormData();
formData.append('name', `${proofName} — ${entry.slot}`);
@@ -906,8 +927,45 @@ export class CreateClientComponent {
return this.clientsService.getKycProofTypes('id').pipe(
map((res: any) => {
const list: any[] = res?.idProofs || [];
const targetName = (proofName || '').trim().toLowerCase();
const match = list.find((p: any) => (p?.name || '').trim().toLowerCase() === targetName);
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const target = cleanStr(proofName || proofId);
// 1. Try exact code/name match
let match = list.find((p: any) => {
const pcode = cleanStr(p.code);
const pname = cleanStr(p.name);
return pcode === target || pname === target;
});
// 2. Try loose contains/includes match
if (!match) {
match = list.find((p: any) => {
const pcode = cleanStr(p.code);
const pname = cleanStr(p.name);
return pcode.includes(target) || pname.includes(target) || target.includes(pcode) || target.includes(pname);
});
}
// 3. Specific fallbacks for common keywords
if (!match && target.includes('voter')) {
match = list.find((p: any) => {
const name = cleanStr(p.name);
return name.includes('voter') || name.includes('epic');
});
}
if (!match && target.includes('aadhaar')) {
match = list.find((p: any) => cleanStr(p.name).includes('aadhaar'));
}
if (!match && target.includes('passport')) {
match = list.find((p: any) => cleanStr(p.name).includes('passport'));
}
if (!match && target.includes('driving')) {
match = list.find((p: any) => cleanStr(p.name).includes('drive') || cleanStr(p.name).includes('licen'));
}
if (!match && target.includes('pan')) {
match = list.find((p: any) => cleanStr(p.name).includes('pan'));
}
return (match?.id ?? null) as number | null;
})
);
@@ -220,6 +220,8 @@
[isNonIndividual]="legalFormType === 2"
[isMinor]="clientGeneralStep?.isMinor || false"
[clientId]="clientId"
[verificationName]="clientGeneralStep?.verificationName || ''"
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
[readOnly]="isTabReadOnly(1)"
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
></mifosx-client-kyc-step>
@@ -342,7 +344,6 @@
[degreeQualificationOptions]="clientGeneralStep?.degreeQualificationOptions || []"
[religionOptions]="clientGeneralStep?.religionOptions || []"
[socialCategoryOptions]="clientGeneralStep?.socialCategoryOptions || []"
(submitEvent)="submit()"
></mifosx-client-preview-step>
</div>
</mat-tab>
@@ -350,9 +351,11 @@
<!-- Bottom Action Bar -->
<div class="action-bar">
<button mat-raised-button [routerLink]="['../']" class="cancel-btn">
{{ 'labels.buttons.Cancel' | translate }}
</button>
@if (selectedTabIndex < totalTabs - 1) {
<button mat-raised-button [routerLink]="['../']" class="cancel-btn">
{{ 'labels.buttons.Cancel' | translate }}
</button>
}
<div class="action-bar-right">
@if (selectedTabIndex > 0) {
<button mat-raised-button (click)="selectedTabIndex = selectedTabIndex - 1">
@@ -361,15 +364,7 @@
</button>
}
@if (selectedTabIndex < totalTabs - 1) {
<button
mat-raised-button
color="primary"
(click)="onNext()"
[disabled]="!currentStepValid"
[matTooltip]="currentStepBlockerMessage"
[matTooltipDisabled]="currentStepValid"
matTooltipPosition="above"
>
<button mat-raised-button color="primary" (click)="onNext()" [disabled]="savingClient || !currentStepValid">
@if (savingClient) {
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
Saving...
@@ -383,15 +378,10 @@
}
</button>
}
@if (selectedTabIndex === totalTabs - 1 && areFormvalids()) {
<button mat-raised-button color="primary" (click)="submit()" [disabled]="savingClient">
@if (savingClient) {
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
Saving...
} @else {
<fa-icon icon="check" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Submit' | translate }}
}
@if (selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" [routerLink]="['../']">
<fa-icon icon="check" class="m-r-10"></fa-icon>
Finish
</button>
}
</div>
@@ -1021,6 +1021,9 @@ export class EditClientComponent {
* Mirrors identities-tab.createIdentifierWithDocs.
*/
private saveAddressProof(clientId: string) {
if (this.clientIndiaAddressStep?.existingAddressProofIdentifierId) {
return;
}
const payload = this.clientIndiaAddressStep?.addressProofPayload;
if (!payload?.proofId) return;
@@ -1036,6 +1039,9 @@ export class EditClientComponent {
next: (res: any) => {
const identifierId = res?.resourceId;
if (!identifierId) return;
if (this.clientIndiaAddressStep) {
this.clientIndiaAddressStep.existingAddressProofIdentifierId = identifierId.toString();
}
for (const entry of payload.files) {
const formData = new FormData();
formData.append('name', `${proofName} — ${entry.slot}`);
@@ -1056,6 +1062,21 @@ export class EditClientComponent {
if (!payload?.proofId || !payload?.files?.length) return;
const proofName = payload.proof?.name || 'KYC Document';
const uploadFiles = (identifierId: string) => {
for (const entry of payload.files) {
const formData = new FormData();
formData.append('name', `${proofName} — ${entry.slot}`);
formData.append('file', entry.file);
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
}
};
if (this.clientKycStep?.existingKycIdentifierId) {
uploadFiles(this.clientKycStep.existingKycIdentifierId);
return;
}
// The KYC step's chip-picker carries `id` as a doc-type CODE string (e.g. "AADHAAR").
// The backend ClientIdentifierRequest.documentTypeId is a Long, so a string proofId
// trips Jackson → HttpMessageNotReadableException → 400. Resolve to numeric CodeValue
@@ -1075,12 +1096,10 @@ export class EditClientComponent {
next: (res: any) => {
const identifierId = res?.resourceId;
if (!identifierId) return;
for (const entry of payload.files) {
const formData = new FormData();
formData.append('name', `${proofName} — ${entry.slot}`);
formData.append('file', entry.file);
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
if (this.clientKycStep) {
this.clientKycStep.existingKycIdentifierId = identifierId.toString();
}
uploadFiles(identifierId.toString());
},
error: () => {}
});
@@ -1098,8 +1117,45 @@ export class EditClientComponent {
return this.clientsService.getKycProofTypes('id').pipe(
map((res: any) => {
const list: any[] = res?.idProofs || [];
const targetName = (proofName || '').trim().toLowerCase();
const match = list.find((p: any) => (p?.name || '').trim().toLowerCase() === targetName);
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const target = cleanStr(proofName || proofId);
// 1. Try exact code/name match
let match = list.find((p: any) => {
const pcode = cleanStr(p.code);
const pname = cleanStr(p.name);
return pcode === target || pname === target;
});
// 2. Try loose contains/includes match
if (!match) {
match = list.find((p: any) => {
const pcode = cleanStr(p.code);
const pname = cleanStr(p.name);
return pcode.includes(target) || pname.includes(target) || target.includes(pcode) || target.includes(pname);
});
}
// 3. Specific fallbacks for common keywords
if (!match && target.includes('voter')) {
match = list.find((p: any) => {
const name = cleanStr(p.name);
return name.includes('voter') || name.includes('epic');
});
}
if (!match && target.includes('aadhaar')) {
match = list.find((p: any) => cleanStr(p.name).includes('aadhaar'));
}
if (!match && target.includes('passport')) {
match = list.find((p: any) => cleanStr(p.name).includes('passport'));
}
if (!match && target.includes('driving')) {
match = list.find((p: any) => cleanStr(p.name).includes('drive') || cleanStr(p.name).includes('licen'));
}
if (!match && target.includes('pan')) {
match = list.find((p: any) => cleanStr(p.name).includes('pan'));
}
return (match?.id ?? null) as number | null;
})
);
@@ -0,0 +1,136 @@
/**
* 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 {
deriveKycDocumentRenewal,
kycDocumentNeedsAttention,
KYC_DOCUMENT_EXPIRY_WARNING_DAYS,
KYC_DOCUMENT_RENEWAL_YEARS
} from './kyc-document-renewal.util';
const TODAY = new Date(2026, 7, 7); // 07 Aug 2026
const at = (y: number, m: number, d: number) => new Date(y, m - 1, d);
const check = (issueDate: Date | null, expiryDate: Date | null = null, today: Date = TODAY) =>
deriveKycDocumentRenewal({ issueDate, expiryDate, today });
describe('deriveKycDocumentRenewal', () => {
describe('the five-year rule', () => {
it('says nothing about a document with neither date captured', () => {
const result = check(null, null);
expect(result.state).toBe('UNKNOWN');
expect(result.dueOn).toBeNull();
expect(result.daysRemaining).toBeNull();
});
it('leaves a recently issued document alone', () => {
const result = check(at(2025, 1, 15));
expect(result.state).toBe('OK');
expect(result.reason).toBe('AGE');
});
it('falls due exactly five years after issue', () => {
const result = check(at(2021, 8, 7));
expect(result.dueOn).toEqual(at(2026, 8, 7));
expect(result.daysRemaining).toBe(0);
// Due today is not yet past — the branch still has the day to act.
expect(result.state).toBe('OK');
});
it('becomes DUE the day after the fifth anniversary', () => {
const result = check(at(2021, 8, 6));
expect(result.state).toBe('DUE');
expect(result.reason).toBe('AGE');
});
it('is DUE, not EXPIRED, once past five years — the document is still valid, just stale', () => {
const result = check(at(2020, 3, 10));
expect(result.state).toBe('DUE');
expect(result.reason).toBe('AGE');
expect(result.daysRemaining).toBeLessThan(0);
});
it('does not warn ahead of the five-year mark — a document one day short is entirely fine', () => {
// Due tomorrow on age alone, and still OK: only expiry gets an advance warning.
const result = check(at(2021, 8, 8));
expect(result.daysRemaining).toBe(1);
expect(result.state).toBe('OK');
});
});
describe('an explicit expiry date', () => {
it('warns seven days before expiry', () => {
const result = check(at(2025, 6, 1), at(2026, 8, 14));
expect(result.reason).toBe('EXPIRY');
expect(result.daysRemaining).toBe(7);
expect(result.state).toBe('EXPIRING_SOON');
});
it('is still OK eight days out', () => {
expect(check(at(2025, 6, 1), at(2026, 8, 15)).state).toBe('OK');
});
it('warns on the day of expiry itself', () => {
const result = check(at(2025, 6, 1), at(2026, 8, 7));
expect(result.daysRemaining).toBe(0);
expect(result.state).toBe('EXPIRING_SOON');
});
it('marks a passed expiry as EXPIRED — no longer a valid OVD', () => {
const result = check(at(2024, 1, 1), at(2026, 1, 1));
expect(result.state).toBe('EXPIRED');
expect(result.reason).toBe('EXPIRY');
});
it('governs when it falls before the five-year mark', () => {
// Issued last year, so the age clock is years away — expiry is what matters.
const result = check(at(2025, 6, 1), at(2026, 10, 1));
expect(result.reason).toBe('EXPIRY');
expect(result.dueOn).toEqual(at(2026, 10, 1));
});
it('yields to the five-year rule when it falls later', () => {
// A passport issued in 2019 expiring in 2029 is stale long before it expires.
const result = check(at(2019, 5, 20), at(2029, 5, 20));
expect(result.reason).toBe('AGE');
expect(result.dueOn).toEqual(at(2024, 5, 20));
expect(result.state).toBe('DUE');
});
it('is judged on expiry alone when no issue date was captured', () => {
const result = check(null, at(2027, 1, 1));
expect(result.reason).toBe('EXPIRY');
expect(result.state).toBe('OK');
});
});
describe('leap-year issue dates', () => {
it('pins a 29 February issue to 28 February, not 1 March', () => {
// 2020-02-29 + 5 years has no real anniversary; rolling into March would push the
// document into the wrong month.
const result = check(at(2020, 2, 29));
expect(result.dueOn).toEqual(at(2025, 2, 28));
});
});
describe('kycDocumentNeedsAttention', () => {
it('flags only the states staff must act on', () => {
expect(kycDocumentNeedsAttention('UNKNOWN')).toBe(false);
expect(kycDocumentNeedsAttention('OK')).toBe(false);
expect(kycDocumentNeedsAttention('EXPIRING_SOON')).toBe(true);
expect(kycDocumentNeedsAttention('EXPIRED')).toBe(true);
expect(kycDocumentNeedsAttention('DUE')).toBe(true);
});
});
it('uses a five-year age window and a seven-day expiry warning', () => {
expect(KYC_DOCUMENT_RENEWAL_YEARS).toBe(5);
expect(KYC_DOCUMENT_EXPIRY_WARNING_DAYS).toBe(7);
});
});
@@ -0,0 +1,152 @@
/**
* 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/.
*/
/**
* Decides when the officially valid document (OVD) captured at KYC needs updating.
*
* The KYC step records a document type, its number, an issue date and — for documents that carry
* one — an expiry date. Nothing has ever read those dates back, so a client onboarded on a
* ten-year-old driving licence looked exactly like one onboarded yesterday. This module turns the
* two dates into a single answer to "does this document need updating?", so the KYC tab can say so
* rather than leaving staff to work it out from a date on a form.
*
* Two separate clocks run, and they warn differently because they mean different things:
*
* - **Age.** Five years after issue the document is stale and must be re-collected. It is still a
* valid document, so there is nothing to pre-empt — the alert appears once the date passes.
* - **Expiry.** A printed expiry date is a hard stop: the day after, the document is not a valid
* OVD at all and the account is out of compliance. That one is worth heading off, so it warns
* seven days ahead while the customer can still be asked to bring the renewed document in.
*/
/** A document is treated as due for refresh five years after it was issued. */
export const KYC_DOCUMENT_RENEWAL_YEARS = 5;
/** How many days before a printed expiry date the "expires soon" warning starts. */
export const KYC_DOCUMENT_EXPIRY_WARNING_DAYS = 7;
export type KycDocumentRenewalState =
/** No dates captured — nothing can be said about this document. */
| 'UNKNOWN'
/** Within its life, and not close to either threshold. */
| 'OK'
/** Printed expiry date falls within the next seven days. */
| 'EXPIRING_SOON'
/** Past its printed expiry date: no longer a valid OVD. */
| 'EXPIRED'
/** Past the five-year mark: still a valid document, but must be re-collected. */
| 'DUE';
/** Which of the two clocks decided the outcome. */
export type KycDocumentRenewalReason = 'EXPIRY' | 'AGE' | null;
export interface KycDocumentRenewalInput {
/** Issue date printed on the document. */
issueDate: Date | null;
/** Expiry date printed on the document — only some carry one (passport, DL, visa). */
expiryDate: Date | null;
/** Today, injected so the calculation is testable and matches the tenant's date. */
today: Date;
}
export interface KycDocumentRenewalStatus {
state: KycDocumentRenewalState;
/** The date the document falls due — the earlier of its expiry and its fifth anniversary. */
dueOn: Date | null;
/** Days until `dueOn`; negative once it has passed. Null when nothing can be judged. */
daysRemaining: number | null;
reason: KycDocumentRenewalReason;
}
/** Midnight-aligned, so a document due today is not counted as a fraction of a day overdue. */
function startOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
/**
* Adds whole years, clamping to the end of the month.
*
* A 29 February issue date has no fifth anniversary. Letting the Date constructor roll it into
* 1 March would push the document into the wrong month, so it is pinned to 28 February instead.
*/
function addYears(date: Date, years: number): Date {
const target = new Date(date.getFullYear() + years, date.getMonth(), date.getDate());
if (target.getMonth() !== date.getMonth()) {
return new Date(target.getFullYear(), target.getMonth(), 0);
}
return target;
}
const MS_PER_DAY = 24 * 60 * 60 * 1000;
function daysBetween(from: Date, to: Date): number {
return Math.round((to.getTime() - from.getTime()) / MS_PER_DAY);
}
/**
* Works out whether the captured document needs updating.
*
* The two dates are not alternatives — whichever falls first governs. A passport issued last year
* but expiring in six months is due in six months, not in four years: treating the five-year rule
* as the only clock would leave an expired passport on file looking current. A document with no
* printed expiry is judged purely on age.
*/
export function deriveKycDocumentRenewal(input: KycDocumentRenewalInput): KycDocumentRenewalStatus {
const today = startOfDay(input.today);
const issue = input.issueDate ? startOfDay(input.issueDate) : null;
const expiry = input.expiryDate ? startOfDay(input.expiryDate) : null;
if (!issue && !expiry) {
return { state: 'UNKNOWN', dueOn: null, daysRemaining: null, reason: null };
}
const ageDue = issue ? addYears(issue, KYC_DOCUMENT_RENEWAL_YEARS) : null;
// Whichever comes first governs — see the doc comment above.
let dueOn: Date;
let reason: KycDocumentRenewalReason;
if (expiry && ageDue) {
if (expiry.getTime() <= ageDue.getTime()) {
dueOn = expiry;
reason = 'EXPIRY';
} else {
dueOn = ageDue;
reason = 'AGE';
}
} else if (expiry) {
dueOn = expiry;
reason = 'EXPIRY';
} else {
dueOn = ageDue as Date;
reason = 'AGE';
}
const daysRemaining = daysBetween(today, dueOn);
let state: KycDocumentRenewalState;
if (reason === 'EXPIRY') {
if (daysRemaining < 0) {
state = 'EXPIRED';
} else if (daysRemaining <= KYC_DOCUMENT_EXPIRY_WARNING_DAYS) {
state = 'EXPIRING_SOON';
} else {
state = 'OK';
}
} else {
// Age alone: a document one day short of five years is entirely fine, so there is nothing to
// warn about in advance. It becomes due the moment the anniversary passes.
state = daysRemaining < 0 ? 'DUE' : 'OK';
}
return { state, dueOn, daysRemaining, reason };
}
/** Whether this state warrants surfacing an alert to staff at all. */
export function kycDocumentNeedsAttention(state: KycDocumentRenewalState): boolean {
return state === 'EXPIRING_SOON' || state === 'EXPIRED' || state === 'DUE';
}
+111
View File
@@ -93,6 +93,117 @@ describe('deriveKycExceptionFields', () => {
});
});
describe('PAN declaration', () => {
it('waives the PAN exception once a declaration is taken', () => {
// The client's name/DOB will never match the PAN record, so the API can never pass. The
// signed declaration closes the item instead — the client must not sit in the exception list.
expect(
deriveKycExceptionFields({
...newIndividual(false, true),
panDeclarationTaken: true
})
).toEqual({
isException: false,
exceptionReason: null,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('still raises the PAN exception when no declaration has been taken', () => {
expect(
deriveKycExceptionFields({
...newIndividual(false, true),
panDeclarationTaken: false
}).exceptionReason
).toBe(KYC_EXCEPTION_PAN);
});
it('leaves an outstanding Aadhaar untouched — a PAN declaration covers PAN only', () => {
const result = deriveKycExceptionFields({
...newIndividual(false, false),
panDeclarationTaken: true
});
expect(result.isException).toBe(true);
expect(result.exceptionReason).toBe(KYC_EXCEPTION_AADHAAR);
});
it('closes an existing PAN exception when a declaration is taken afterwards', () => {
expect(
deriveKycExceptionFields({
...existingException(false, true, KYC_EXCEPTION_PAN),
panDeclarationTaken: true
})
).toEqual({
isException: false,
exceptionReason: KYC_EXCEPTION_PAN,
exceptionResolved: true,
exceptionResolvedOn: TODAY
});
});
});
describe('Aadhaar enrolment receipt', () => {
it('waives the Aadhaar exception once the enrolment receipt is recorded', () => {
// The client holds no Aadhaar yet, so there is no number to run the OTP check against. The
// UIDAI enrolment slip closes the item instead.
expect(
deriveKycExceptionFields({
...newIndividual(true, false),
aadhaarReceiptTaken: true
})
).toEqual({
isException: false,
exceptionReason: null,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('still raises the Aadhaar exception when no receipt has been taken', () => {
expect(
deriveKycExceptionFields({
...newIndividual(true, false),
aadhaarReceiptTaken: false
}).exceptionReason
).toBe(KYC_EXCEPTION_AADHAAR);
});
it('leaves an outstanding PAN untouched — a receipt covers Aadhaar only', () => {
const result = deriveKycExceptionFields({
...newIndividual(false, false),
aadhaarReceiptTaken: true
});
expect(result.isException).toBe(true);
expect(result.exceptionReason).toBe(KYC_EXCEPTION_PAN);
});
it('closes an existing Aadhaar exception when a receipt is taken afterwards', () => {
expect(
deriveKycExceptionFields({
...existingException(true, false, KYC_EXCEPTION_AADHAAR),
aadhaarReceiptTaken: true
})
).toEqual({
isException: false,
exceptionReason: KYC_EXCEPTION_AADHAAR,
exceptionResolved: true,
exceptionResolvedOn: TODAY
});
});
it('clears both items when a PAN declaration and an Aadhaar receipt are both on file', () => {
// Neither check can pass, yet the branch holds paperwork for both — nothing is outstanding.
const result = deriveKycExceptionFields({
...newIndividual(false, false),
panDeclarationTaken: true,
aadhaarReceiptTaken: true
});
expect(result.isException).toBe(false);
expect(result.exceptionReason).toBeNull();
});
});
describe('resolving', () => {
it('closes the exception once both checks pass, keeping the reason for the audit trail', () => {
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
+25 -2
View File
@@ -29,8 +29,28 @@ export const AUTO_MANAGED_KYC_EXCEPTION_REASONS: string[] = [
export interface KycExceptionInput {
/** PAN verified against the Income Tax database. Unverified OR simply not entered → exception. */
panVerified: boolean;
/**
* A signed customer declaration has been accepted in place of a passing PAN check.
*
* The Income Tax check matches on name *and* date of birth, so a client whose PAN card carries a
* different spelling — or a different DOB — can never clear it, however many times the officer
* retries. Once the branch holds a declaration recording exactly what differs, the outstanding
* item is closed by paperwork rather than by the API, and it must not keep the client sitting in
* the exception list. PAN still reads as unverified everywhere else; only the exception is waived.
*/
panDeclarationTaken?: boolean;
/** Aadhaar verified against UIDAI. Unverified OR simply not entered → exception. */
aadhaarVerified: boolean;
/**
* The customer's Aadhaar enrolment slip has been sighted and recorded.
*
* A customer who has applied for Aadhaar but has not been issued one yet holds only a UIDAI
* enrolment receipt. There is no Aadhaar number to put through the OTP flow, so the check cannot
* pass however long the branch waits — the same dead end the PAN declaration exists to resolve.
* Once the receipt is on file the outstanding item is closed by paperwork, and the client must
* not keep sitting in the exception list. Aadhaar still reads as unverified everywhere else.
*/
aadhaarReceiptTaken?: boolean;
/**
* False for non-individual clients, where no Aadhaar is collected (the payload sends a dummy
* masked value). Without this guard every company would carry a permanent Aadhaar exception.
@@ -69,10 +89,13 @@ export interface KycExceptionFields {
export function deriveKycExceptionFields(input: KycExceptionInput): KycExceptionFields {
const outstanding: string[] = [];
if (!input.panVerified) {
// A declaration settles the PAN item without the API ever passing — see `panDeclarationTaken`.
if (!input.panVerified && !input.panDeclarationTaken) {
outstanding.push(KYC_EXCEPTION_PAN);
}
if (input.aadhaarApplicable && !input.aadhaarVerified) {
// An enrolment receipt settles the Aadhaar item when the OTP check has nothing to run against —
// see `aadhaarReceiptTaken`.
if (input.aadhaarApplicable && !input.aadhaarVerified && !input.aadhaarReceiptTaken) {
outstanding.push(KYC_EXCEPTION_AADHAAR);
}
@@ -0,0 +1,84 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="aad-rcpt">
<div class="aad-rcpt__header">
<mat-icon class="aad-rcpt__header-icon">receipt_long</mat-icon>
<div>
<div class="aad-rcpt__title">Aadhaar Enrolment Receipt</div>
<div class="aad-rcpt__subtitle">{{ data.clientName || 'Client' }}</div>
</div>
</div>
<mat-dialog-content class="aad-rcpt__body">
<div class="aad-rcpt__reason">
<mat-icon>info</mat-icon>
<span>{{ data.reason }}</span>
</div>
<p class="aad-rcpt__intro">
For a customer who has applied for Aadhaar but has not been issued one yet, there is no Aadhaar number to run the
OTP check against. Record the UIDAI enrolment slip instead — the client can then be created without an Aadhaar KYC
exception being raised.
</p>
<mat-form-field appearance="outline" class="aad-rcpt__field">
<mat-label>Enrolment number (EID)</mat-label>
<span matPrefix class="aad-rcpt__prefix"><mat-icon>confirmation_number</mat-icon></span>
<input matInput [(ngModel)]="enrolmentId" placeholder="1234/56789/01234" maxlength="20" autocomplete="off" />
<mat-hint [class.aad-rcpt__hint--bad]="enrolmentIdEntered && !enrolmentIdValid">
{{ enrolmentIdHint }}
</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="aad-rcpt__field">
<mat-label>Enrolment date</mat-label>
<input matInput [matDatepicker]="enrolPicker" [max]="today" [(ngModel)]="enrolmentDate" />
<mat-datepicker-toggle matSuffix [for]="enrolPicker"></mat-datepicker-toggle>
<mat-datepicker #enrolPicker></mat-datepicker>
<mat-hint>Date printed on the slip</mat-hint>
</mat-form-field>
@if (monthsSinceEnrolment !== null) {
<div class="aad-rcpt__age" [class.aad-rcpt__age--stale]="receiptIsStale">
<mat-icon>{{ receiptIsStale ? 'warning' : 'schedule' }}</mat-icon>
<span>
@if (receiptIsStale) {
Enrolled {{ monthsSinceEnrolment }} months ago. An enrolment normally completes well inside six months —
chase the customer for the Aadhaar itself. The receipt can still be recorded.
} @else {
Enrolled {{ monthsSinceEnrolment }} month{{ monthsSinceEnrolment === 1 ? '' : 's' }} ago.
}
</span>
</div>
}
<mat-form-field appearance="outline" class="aad-rcpt__remarks">
<mat-label>Remarks</mat-label>
<textarea
matInput
rows="3"
[(ngModel)]="remarks"
maxlength="500"
placeholder="What was sighted, and when the Aadhaar is expected"
></textarea>
<mat-hint>{{ remarks.length }}/500</mat-hint>
</mat-form-field>
<mat-checkbox class="aad-rcpt__sighted" [(ngModel)]="sighted">
The original UIDAI enrolment slip has been sighted and a copy filed with the account opening form.
</mat-checkbox>
</mat-dialog-content>
<mat-dialog-actions align="end" class="aad-rcpt__actions">
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
<button mat-flat-button color="primary" type="button" [disabled]="!canSubmit" (click)="submit()">
Save Receipt
</button>
</mat-dialog-actions>
</div>
@@ -0,0 +1,136 @@
/**
* 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/.
*/
.aad-rcpt {
max-width: 560px;
}
.aad-rcpt__header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 24px 12px;
border-bottom: 1px solid #bfdbfe;
background: #eff6ff;
}
.aad-rcpt__header-icon {
color: #2563eb;
flex: 0 0 auto;
}
.aad-rcpt__title {
font-size: 16px;
font-weight: 600;
color: #1e3a8a;
}
.aad-rcpt__subtitle {
font-size: 12px;
color: #2563eb;
}
.aad-rcpt__body {
padding: 18px 24px !important;
font-size: 14px;
color: #475569;
}
.aad-rcpt__reason {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
margin-bottom: 14px;
border-radius: 6px;
background: #f8fafc;
border: 1px solid #e2e8f0;
color: #475569;
font-size: 13px;
line-height: 1.5;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex: 0 0 auto;
color: #64748b;
}
}
.aad-rcpt__intro {
margin: 0 0 16px;
font-size: 13px;
line-height: 1.6;
}
.aad-rcpt__field,
.aad-rcpt__remarks {
width: 100%;
}
.aad-rcpt__prefix {
display: inline-flex;
align-items: center;
margin-right: 8px;
color: #94a3b8;
}
.aad-rcpt__hint--bad {
color: #dc2626;
}
// Age of the slip — informational, so it reads as context rather than a blocking error.
.aad-rcpt__age {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 2px 0 14px;
padding: 9px 12px;
border-radius: 6px;
background: #f8fafc;
border: 1px solid #e2e8f0;
font-size: 12.5px;
line-height: 1.5;
color: #475569;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex: 0 0 auto;
color: #64748b;
}
&--stale {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
mat-icon {
color: #d97706;
}
}
}
.aad-rcpt__sighted {
display: block;
margin-top: 4px;
font-size: 13px;
line-height: 1.5;
::ng-deep .mdc-form-field > label {
white-space: normal;
}
}
.aad-rcpt__actions {
padding: 10px 24px 16px !important;
margin: 0 !important;
gap: 8px;
}
@@ -0,0 +1,151 @@
/**
* 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, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export interface AadhaarReceiptDialogData {
/** Client name, shown so the officer can check the slip belongs to the right person. */
clientName: string;
/** Message the Aadhaar step came back with, or why the officer is recording a receipt. */
reason: string;
/** Existing receipt, when the officer re-opens one already taken. */
existing?: AadhaarReceiptResult | null;
}
export interface AadhaarReceiptResult {
/** 14-digit enrolment number as printed on the slip, separators kept. */
enrolmentId: string;
enrolmentDate: Date | null;
remarks: string;
}
/**
* Records the UIDAI enrolment receipt accepted in place of a verified Aadhaar.
*
* A customer who has applied for Aadhaar but has not been issued one holds only an enrolment slip.
* There is no Aadhaar number to send to the OTP flow, so no amount of retrying will clear the
* check — the same dead end the PAN declaration resolves. Recording the slip lets the client be
* created and waives the Aadhaar exception, while Aadhaar itself stays unverified.
*
* Only the 14-digit enrolment number is taken. The date and time printed beside it on the slip are
* captured by the enrolment-date field, so asking for them again in the same box would just be a
* second chance to mistype them.
*
* The number is validated on digit count rather than an exact layout: slips print it with varying
* separators, and rejecting one because of a slash or a space would block a customer over
* formatting rather than substance.
*/
@Component({
selector: 'mifosx-aadhaar-receipt-dialog',
templateUrl: './aadhaar-receipt-dialog.component.html',
styleUrls: ['./aadhaar-receipt-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
MatIcon,
MatDialogContent,
MatDialogActions,
MatDialogClose
]
})
export class AadhaarReceiptDialogComponent {
data = inject<AadhaarReceiptDialogData>(MAT_DIALOG_DATA);
private dialogRef = inject(MatDialogRef<AadhaarReceiptDialogComponent>);
enrolmentId = '';
enrolmentDate: Date | null = null;
remarks = '';
/** The officer confirms the original slip was sighted — nothing is saved without it. */
sighted = false;
today = new Date();
constructor() {
const existing = this.data.existing;
if (existing) {
this.enrolmentId = existing.enrolmentId || '';
this.enrolmentDate = existing.enrolmentDate || null;
this.remarks = existing.remarks || '';
}
}
/** Digits only — separators on the printed slip are cosmetic and vary between forms. */
private get enrolmentDigits(): string {
return (this.enrolmentId || '').replace(/\D/g, '');
}
get enrolmentIdEntered(): boolean {
return this.enrolmentDigits.length > 0;
}
/** UIDAI's enrolment number is 14 digits — the timestamp beside it is not part of it. */
get enrolmentIdValid(): boolean {
return this.enrolmentDigits.length === 14;
}
/** How many digits are still missing, so the officer is told what to fix, not just that it failed. */
get enrolmentIdHint(): string {
const count = this.enrolmentDigits.length;
if (!count) {
return 'The 14-digit enrolment number only, e.g. 1234/56789/01234';
}
return `${count} of 14 digits`;
}
/** A slip dated in the future is a keying error, not a valid enrolment. */
get enrolmentDateValid(): boolean {
return !!this.enrolmentDate && this.enrolmentDate.getTime() <= this.today.getTime();
}
/** Whole months since enrolment — shown because a receipt is temporary by nature. */
get monthsSinceEnrolment(): number | null {
if (!this.enrolmentDate) {
return null;
}
const months =
(this.today.getFullYear() - this.enrolmentDate.getFullYear()) * 12 +
(this.today.getMonth() - this.enrolmentDate.getMonth()) -
(this.today.getDate() < this.enrolmentDate.getDate() ? 1 : 0);
return Math.max(0, months);
}
/**
* An enrolment normally completes well inside six months, so an older slip is a prompt to chase
* the customer for the Aadhaar itself. It is a warning, not a block: the branch may have good
* reason to accept it, and the record shows the age either way.
*/
get receiptIsStale(): boolean {
const months = this.monthsSinceEnrolment;
return months !== null && months >= 6;
}
get canSubmit(): boolean {
return this.enrolmentIdValid && this.enrolmentDateValid && !!this.remarks.trim() && this.sighted;
}
submit(): void {
if (!this.canSubmit) {
return;
}
this.dialogRef.close({
enrolmentId: this.enrolmentId.trim(),
enrolmentDate: this.enrolmentDate,
remarks: this.remarks.trim()
} as AadhaarReceiptResult);
}
}
@@ -84,6 +84,121 @@ export class KycUploadHelperService {
);
}
/**
* Split a stacked front-above-back scan back into its two sides.
*
* Clients saved before the sides were stored separately carry one merged PNG, so a form that
* wants a front image and a back image has nothing to show in the second slot. `mergeFrontBack`
* lays the sides on a white canvas with a 20px gap between them, which leaves a band of
* completely white rows across the full width — that band is the seam, and it is the only thing
* in the file that says where one side ends and the other begins.
*
* Resolves null when no such seam is found, which is the honest answer for an image that was not
* produced by that merge: better to show one scan than to cut a document in half at a guess.
*/
splitFrontBack(blob: Blob): Promise<{ front: Blob; back: Blob } | null> {
if (!blob.type.startsWith('image/')) {
return Promise.resolve(null);
}
type Sides = { front: Blob; back: Blob } | null;
return this.blobToImage(blob)
.then((img): Sides | Promise<Sides> => {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
const ctx = canvas.getContext('2d');
if (!ctx || canvas.width === 0 || canvas.height === 0) {
return null;
}
ctx.drawImage(img, 0, 0);
const seam = this.findWhiteSeam(ctx, canvas.width, canvas.height);
if (!seam) {
return null;
}
return Promise.all([
this.cropToBlob(img, canvas.width, 0, seam.start),
this.cropToBlob(img, canvas.width, seam.end, canvas.height - seam.end)
]).then(
([
front,
back
]): Sides => (front && back ? { front, back } : null)
);
})
.catch((): Sides => null);
}
/**
* The tallest run of all-white rows in the middle of the image.
*
* Confined to the middle half because a scan often has white margins at the top and bottom, and
* those are part of the document rather than the join between two of them.
*/
private findWhiteSeam(
ctx: CanvasRenderingContext2D,
width: number,
height: number
): { start: number; end: number } | null {
const from = Math.floor(height * 0.25);
const to = Math.ceil(height * 0.75);
const step = Math.max(1, Math.floor(width / 64)); // sampling is enough to spot a non-white row
let bestStart = -1;
let bestLength = 0;
let runStart = -1;
for (let y = from; y <= to; y++) {
let white = y < to;
if (white) {
const row = ctx.getImageData(0, y, width, 1).data;
for (let x = 0; x < width; x += step) {
const i = x * 4;
if (row[i] < 245 || row[i + 1] < 245 || row[i + 2] < 245) {
white = false;
break;
}
}
}
if (white) {
if (runStart < 0) {
runStart = y;
}
} else if (runStart >= 0) {
if (y - runStart > bestLength) {
bestStart = runStart;
bestLength = y - runStart;
}
runStart = -1;
}
}
// The gap the merge inserts is 20px; anything much thinner is white space inside a document.
return bestLength >= 12 ? { start: bestStart, end: bestStart + bestLength } : null;
}
private cropToBlob(img: HTMLImageElement, width: number, top: number, height: number): Promise<Blob | null> {
if (height <= 0) {
return Promise.resolve(null);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
return Promise.resolve(null);
}
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, top, width, height, 0, 0, width, height);
return new Promise((resolve) => canvas.toBlob((out) => resolve(out), 'image/png'));
}
private blobToImage(blob: Blob): Promise<HTMLImageElement> {
return this.fileToImage(blob as File);
}
private fileToImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
@@ -0,0 +1,176 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pan-decl">
<div class="pan-decl__header">
<mat-icon class="pan-decl__header-icon">assignment_late</mat-icon>
<div>
<div class="pan-decl__title">PAN Verification Failed — Take Declaration</div>
<div class="pan-decl__subtitle">PAN {{ data.panNumber }}</div>
</div>
</div>
<mat-dialog-content class="pan-decl__body">
<div class="pan-decl__reason">
<mat-icon>info</mat-icon>
<span>{{ data.failureMessage }}</span>
</div>
@if (!data.unverifiable) {
<p class="pan-decl__intro">
The Income Tax check matches on <strong>name and date of birth together</strong> and does not report which of
the two differs. Enter the details exactly as printed on the customer's PAN card so the declaration records what
actually does not match.
</p>
<!-- Comparison: what we submitted vs what the PAN card says -->
<div class="pan-decl__compare">
<div class="pan-decl__compare-head">
<span class="lbl"></span>
<span class="col">Submitted</span>
<span class="col">As on PAN card</span>
<span class="res"></span>
</div>
<div class="pan-decl__compare-row" [class.mismatch]="nameMismatch">
<span class="lbl">Name</span>
<span class="col submitted">{{ data.submittedName || '—' }}</span>
<span class="col">
<mat-form-field appearance="outline" class="pan-decl__field">
<mat-label>Name on PAN</mat-label>
<input
matInput
[(ngModel)]="nameOnPan"
(ngModelChange)="onCompare()"
placeholder="e.g. RAJESH KUMAR SHARMA"
maxlength="200"
autocomplete="off"
/>
</mat-form-field>
</span>
<span class="res">
@if (compared && nameOnPan.trim()) {
@if (nameMismatch) {
<mat-icon class="res-icon res-icon--bad" aria-label="Does not match">cancel</mat-icon>
} @else {
<mat-icon class="res-icon res-icon--ok" aria-label="Matches">check_circle</mat-icon>
}
}
</span>
</div>
<div class="pan-decl__compare-row" [class.mismatch]="dobMismatch">
<span class="lbl">Date of Birth</span>
<span class="col submitted">{{ data.submittedDob || '—' }}</span>
<span class="col">
<mat-form-field appearance="outline" class="pan-decl__field">
<mat-label>DOB on PAN</mat-label>
<input
matInput
[matDatepicker]="dobPicker"
[max]="today"
[(ngModel)]="dobOnPan"
(ngModelChange)="onCompare()"
/>
<mat-datepicker-toggle matSuffix [for]="dobPicker"></mat-datepicker-toggle>
<mat-datepicker #dobPicker></mat-datepicker>
</mat-form-field>
</span>
<span class="res">
@if (compared && dobOnPan) {
@if (dobMismatch) {
<mat-icon class="res-icon res-icon--bad" aria-label="Does not match">cancel</mat-icon>
} @else {
<mat-icon class="res-icon res-icon--ok" aria-label="Matches">check_circle</mat-icon>
}
}
</span>
</div>
</div>
<!-- What the declaration will state -->
<div
class="pan-decl__verdict"
[class.pan-decl__verdict--pending]="!comparisonComplete"
[class.pan-decl__verdict--clean]="nothingToDeclare"
>
<span class="pan-decl__verdict-label">Does not match:</span>
<span class="pan-decl__verdict-value">{{ mismatchLabel }}</span>
</div>
@if (!comparisonComplete) {
<p class="pan-decl__note">Enter both PAN-card values to see exactly which detail differs.</p>
}
@if (nothingToDeclare) {
<p class="pan-decl__warn">
<mat-icon>warning</mat-icon>
The PAN-card details you entered are the same as the ones submitted, so there is nothing to declare. Correct
the client's name or date of birth on the General step and verify PAN again.
</p>
}
} @else {
<p class="pan-decl__intro">
PAN could not be checked against the Income Tax records. Record the declaration taken from the customer so the
client can be created without a KYC exception being raised for PAN.
</p>
}
<!-- The signed paper is the declaration; it is uploaded, not described. -->
<div class="pan-decl__upload">
<div class="pan-decl__upload-label">Signed declaration</div>
<p class="pan-decl__upload-help">
Get the declaration signed by the customer and upload the scanned copy. It is filed against the client as the
proof that holds the PAN exception off.
</p>
@if (!hasDeclarationFile) {
<label class="pan-decl__dropzone">
<input type="file" [accept]="acceptedTypes" (change)="onDeclarationFile($event)" hidden />
<mat-icon class="pan-decl__dropzone-icon">cloud_upload</mat-icon>
<span class="pan-decl__dropzone-text">Click to upload signed declaration</span>
<span class="pan-decl__dropzone-hint">PDF, JPG or PNG — up to 5 MB</span>
</label>
} @else {
<div class="pan-decl__file">
<mat-icon class="pan-decl__file-icon">description</mat-icon>
<span class="pan-decl__file-meta">
<span class="pan-decl__file-name">{{ fileName }}</span>
<span class="pan-decl__file-sub">{{ fileSizeLabel }}</span>
</span>
<label class="pan-decl__file-replace">
<input type="file" [accept]="acceptedTypes" (change)="onDeclarationFile($event)" hidden />
Replace
</label>
<button
type="button"
class="pan-decl__file-remove"
aria-label="Remove file"
(click)="removeDeclarationFile()"
>
<mat-icon>close</mat-icon>
</button>
</div>
}
@if (fileError) {
<p class="pan-decl__file-error">
<mat-icon>error_outline</mat-icon>
<span>{{ fileError }}</span>
</p>
}
</div>
</mat-dialog-content>
<mat-dialog-actions align="end" class="pan-decl__actions">
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
<button mat-flat-button color="primary" type="button" [disabled]="!canSubmit" (click)="submit()">
Save Declaration
</button>
</mat-dialog-actions>
</div>
@@ -0,0 +1,361 @@
/**
* 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/.
*/
.pan-decl {
max-width: 720px;
}
.pan-decl__header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 24px 12px;
border-bottom: 1px solid #fde68a;
background: #fffbeb;
}
.pan-decl__header-icon {
color: #d97706;
flex: 0 0 auto;
}
.pan-decl__title {
font-size: 16px;
font-weight: 600;
color: #92400e;
}
.pan-decl__subtitle {
font-size: 12px;
color: #b45309;
letter-spacing: 0.03em;
}
.pan-decl__body {
padding: 18px 24px !important;
font-size: 14px;
color: #475569;
}
.pan-decl__reason {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
margin-bottom: 14px;
border-radius: 6px;
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
font-size: 13px;
line-height: 1.5;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex: 0 0 auto;
}
}
.pan-decl__intro {
margin: 0 0 16px;
font-size: 13px;
line-height: 1.6;
}
// ── Submitted vs PAN-card comparison ───────────────────────────────────────
.pan-decl__compare {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.pan-decl__compare-head,
.pan-decl__compare-row {
display: grid;
grid-template-columns: 110px 1fr 1fr 36px;
gap: 12px;
align-items: center;
padding: 8px 14px;
}
.pan-decl__compare-head {
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.pan-decl__compare-row {
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: none;
}
// A row the declaration will name — tinted so the officer can see the finding at a glance.
&.mismatch {
background: #fff7ed;
}
.lbl {
font-size: 13px;
font-weight: 500;
color: #334155;
}
.submitted {
font-size: 13px;
color: #0f172a;
word-break: break-word;
}
}
.pan-decl__field {
width: 100%;
::ng-deep .mat-mdc-form-field-subscript-wrapper {
display: none;
}
}
.res {
display: flex;
justify-content: center;
}
.res-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
.res-icon--ok {
color: #16a34a;
}
.res-icon--bad {
color: #dc2626;
}
// ── Verdict ────────────────────────────────────────────────────────────────
.pan-decl__verdict {
display: flex;
align-items: baseline;
gap: 8px;
margin-top: 14px;
padding: 10px 14px;
border-radius: 6px;
background: #fef2f2;
border: 1px solid #fecaca;
&--pending {
background: #f8fafc;
border-color: #e2e8f0;
}
&--clean {
background: #f0fdf4;
border-color: #bbf7d0;
}
}
.pan-decl__verdict-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.pan-decl__verdict-value {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
.pan-decl__note {
margin: 8px 0 0;
font-size: 12px;
color: #64748b;
}
.pan-decl__warn {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 10px 0 0;
font-size: 12.5px;
line-height: 1.5;
color: #b45309;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex: 0 0 auto;
}
}
// ── Signed declaration upload ──────────────────────────────────────────────
.pan-decl__upload {
margin-top: 18px;
}
.pan-decl__upload-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.pan-decl__upload-help {
margin: 4px 0 10px;
font-size: 12.5px;
line-height: 1.5;
color: #64748b;
}
.pan-decl__dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 18px 24px;
background: #fff;
border: 2px dashed #cbd5e1;
border-radius: 10px;
cursor: pointer;
text-align: center;
transition: all 0.2s;
&:hover {
background: #f8fafc;
border-color: #93c5fd;
}
}
.pan-decl__dropzone-icon {
font-size: 28px;
width: 28px;
height: 28px;
color: #94a3b8;
}
.pan-decl__dropzone-text {
font-size: 13px;
font-weight: 600;
color: #475569;
}
.pan-decl__dropzone-hint {
font-size: 11px;
color: #94a3b8;
}
.pan-decl__file {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
}
.pan-decl__file-icon {
color: #16a34a;
flex: 0 0 auto;
}
.pan-decl__file-meta {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1 1 auto;
}
.pan-decl__file-name {
font-size: 13px;
font-weight: 600;
color: #166534;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pan-decl__file-sub {
font-size: 11px;
color: #4d7c69;
}
.pan-decl__file-replace {
flex: 0 0 auto;
padding: 4px 10px;
border: 1px solid #86efac;
border-radius: 6px;
background: #fff;
font-size: 12px;
font-weight: 600;
color: #166534;
cursor: pointer;
&:hover {
background: #ecfdf5;
}
}
.pan-decl__file-remove {
flex: 0 0 auto;
display: flex;
align-items: center;
padding: 4px;
border: none;
background: transparent;
color: #64748b;
cursor: pointer;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&:hover {
color: #dc2626;
}
}
.pan-decl__file-error {
display: flex;
align-items: flex-start;
gap: 6px;
margin: 8px 0 0;
font-size: 12.5px;
line-height: 1.5;
color: #b91c1c;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex: 0 0 auto;
}
}
.pan-decl__actions {
padding: 10px 24px 16px !important;
margin: 0 !important;
gap: 8px;
}
@@ -0,0 +1,271 @@
/**
* 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, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Which client details differ from the PAN card. */
export type PanMismatchField = 'NAME' | 'DOB';
export interface PanDeclarationDialogData {
/** Name submitted on the General step — the value sent to the Income Tax check. */
submittedName: string;
/** Date of birth submitted on the General step, in the tenant's display format. */
submittedDob: string;
/** PAN being declared against. */
panNumber: string;
/** Message the verification came back with, shown so the officer knows why they are here. */
failureMessage: string;
/**
* True when the check failed on something other than name/DOB (invalid PAN, vendor unreachable).
* The PAN-card fields then have nothing to compare against, so the mismatch reads NONE and the
* officer only has to record why the declaration is being taken.
*/
unverifiable?: boolean;
/** Existing declaration, when the officer re-opens one already taken. */
existing?: PanDeclarationResult | null;
}
export interface PanDeclarationResult {
/** 'NAME', 'DOB', 'NAME,DOB', or 'NONE' when PAN could not be checked at all. */
mismatchFields: string;
nameOnPan: string | null;
dobOnPan: Date | null;
/**
* The scanned signed declaration, to be filed against the client. Null when an existing
* declaration was re-opened and the officer did not replace the document already on file.
*/
file: File | null;
/** Name of the declaration document on record — what the KYC record keeps as the reference. */
fileName: string;
}
/**
* Records the customer declaration that stands in for a passing PAN check.
*
* The Income Tax API matches on name *and* date of birth and tells us only that the pair failed —
* never which half. So rather than show the officer a dead-end error, this dialog asks them to key
* in the two values printed on the physical PAN card in front of them. Comparing those against what
* was submitted is what produces a specific, defensible "NAME does not match" on the declaration,
* and the PAN-card values are kept alongside it as the evidence for that statement.
*
* Name comparison is deliberately loose about case and internal spacing — "RAJESH KUMAR" and
* "Rajesh Kumar" are the same name, and flagging them as a mismatch would put a declaration on file
* that says something untrue.
*
* The declaration itself is taken as an upload rather than typed: what waives the PAN exception is
* the paper the customer signed, so the scan of it is the record — an officer's typed note is not.
*/
@Component({
selector: 'mifosx-pan-declaration-dialog',
templateUrl: './pan-declaration-dialog.component.html',
styleUrls: ['./pan-declaration-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
MatIcon,
MatDialogContent,
MatDialogActions,
MatDialogClose
]
})
export class PanDeclarationDialogComponent {
data = inject<PanDeclarationDialogData>(MAT_DIALOG_DATA);
private dialogRef = inject(MatDialogRef<PanDeclarationDialogComponent>);
/** Name exactly as printed on the PAN card. */
nameOnPan = '';
/** Date of birth exactly as printed on the PAN card. */
dobOnPan: Date | null = null;
/** The scan the officer just picked. Null while an earlier upload is still the one on file. */
declarationFile: File | null = null;
/** Name of the document already filed, when re-opening a declaration that was taken before. */
existingFileName = '';
/** Why the picked file was rejected — shown in place of failing silently. */
fileError = '';
/** Officer has not yet typed anything to compare against. */
compared = false;
today = new Date();
readonly acceptedTypes = '.pdf,.jpg,.jpeg,.png';
private readonly maxFileBytes = 5 * 1024 * 1024;
private readonly acceptedExtensions = [
'pdf',
'jpg',
'jpeg',
'png'
];
constructor() {
const existing = this.data.existing;
if (existing) {
this.nameOnPan = existing.nameOnPan || '';
this.dobOnPan = existing.dobOnPan || null;
this.existingFileName = existing.fileName || '';
this.compared = true;
}
}
/** The document the declaration will be saved against — newly picked, or the one already filed. */
get fileName(): string {
return this.declarationFile?.name || this.existingFileName;
}
get hasDeclarationFile(): boolean {
return !!this.fileName;
}
/** Size of the picked scan, or a note that the document on file is being kept as it is. */
get fileSizeLabel(): string {
if (!this.declarationFile) {
return 'Already on file';
}
const kb = this.declarationFile.size / 1024;
return kb < 1024 ? `${Math.max(1, Math.round(kb))} KB` : `${(kb / 1024).toFixed(1)} MB`;
}
onDeclarationFile(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0] || null;
input.value = ''; // allow re-picking the same filename
if (!file) {
return;
}
const extension = (file.name.split('.').pop() || '').toLowerCase();
if (!this.acceptedExtensions.includes(extension)) {
this.fileError = 'Upload the signed declaration as a PDF, JPG or PNG.';
return;
}
if (file.size > this.maxFileBytes) {
this.fileError = 'The file is larger than 5 MB. Scan at a lower resolution and try again.';
return;
}
this.fileError = '';
this.declarationFile = file;
}
/** Drops the picked scan — and the one already filed, since neither may be left implied. */
removeDeclarationFile(): void {
this.declarationFile = null;
this.existingFileName = '';
this.fileError = '';
}
/** Case- and spacing-insensitive: only a genuinely different name counts as a mismatch. */
private normalizeName(value: string): string {
return (value || '').trim().replace(/\s+/g, ' ').toUpperCase();
}
private formatDob(value: Date | null): string {
if (!value) {
return '';
}
const dd = value.getDate().toString().padStart(2, '0');
const mm = (value.getMonth() + 1).toString().padStart(2, '0');
return `${dd}/${mm}/${value.getFullYear()}`;
}
get nameMismatch(): boolean {
if (this.data.unverifiable || !this.nameOnPan.trim()) {
return false;
}
return this.normalizeName(this.nameOnPan) !== this.normalizeName(this.data.submittedName);
}
get dobMismatch(): boolean {
if (this.data.unverifiable || !this.dobOnPan) {
return false;
}
// Compare on the rendered day/month/year so a time component on either value cannot matter.
return this.formatDob(this.dobOnPan) !== this.normalizeDisplayDob(this.data.submittedDob);
}
/** The submitted DOB arrives pre-formatted for display; reduce it to dd/MM/yyyy for comparison. */
private normalizeDisplayDob(display: string): string {
const parsed = new Date(display);
return isNaN(parsed.getTime()) ? (display || '').trim() : this.formatDob(parsed);
}
/** The fields the declaration will state as differing. */
get mismatchFields(): PanMismatchField[] {
const fields: PanMismatchField[] = [];
if (this.nameMismatch) {
fields.push('NAME');
}
if (this.dobMismatch) {
fields.push('DOB');
}
return fields;
}
get mismatchLabel(): string {
if (this.data.unverifiable) {
return 'PAN could not be verified';
}
const fields = this.mismatchFields;
if (!fields.length) {
return this.compared ? 'No difference found' : 'Not yet compared';
}
return fields.map((f) => (f === 'NAME' ? 'Name' : 'Date of Birth')).join(' and ');
}
/** Both PAN-card values must be keyed in before we can honestly say what differs. */
get comparisonComplete(): boolean {
return !!this.nameOnPan.trim() && !!this.dobOnPan;
}
onCompare(): void {
this.compared = true;
}
/**
* A declaration must actually declare something. When the officer keys in PAN-card values that
* match what was submitted, the mismatch is somewhere else — nothing to sign off here, and the
* right move is to retry the verification rather than paper over it.
*/
get nothingToDeclare(): boolean {
return !this.data.unverifiable && this.comparisonComplete && this.mismatchFields.length === 0;
}
get canSubmit(): boolean {
// The signed scan is the declaration — without it there is nothing to waive the exception on.
if (!this.hasDeclarationFile) {
return false;
}
if (this.data.unverifiable) {
return true;
}
return this.comparisonComplete && this.mismatchFields.length > 0;
}
submit(): void {
if (!this.canSubmit) {
return;
}
const result: PanDeclarationResult = {
mismatchFields: this.data.unverifiable ? 'NONE' : this.mismatchFields.join(','),
nameOnPan: this.data.unverifiable ? null : this.nameOnPan.trim(),
dobOnPan: this.data.unverifiable ? null : this.dobOnPan,
file: this.declarationFile,
fileName: this.fileName
};
this.dialogRef.close(result);
}
}