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
@@ -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 =