Files
cbsnexora/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts
T

1751 lines
65 KiB
TypeScript

/**
* 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/.
*/
/** Angular Imports */
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, forkJoin, map, 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 {
KycDocumentUpdateDialogComponent,
KycDocumentUpdateData,
KycDocumentUpdateResult
} from '../../shared/kyc-document-update-dialog/kyc-document-update-dialog.component';
import { Dates } from 'app/core/utils/dates';
import { jsPDF } from 'jspdf';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** One side of the scanned KYC document, with the preview once it has been fetched. */
interface DocScanSide {
documentId: string;
/** The identifier row this scan hangs off; the two sides can sit on different rows. */
identifierId: string;
fileName: string;
previewUrl: string | null;
}
/**
* KYC Tab Component.
* Displays and manages KYC (Know Your Customer) details for an existing client.
* API: GET/POST /clients/{clientId}/kyc
*/
@Component({
selector: 'mifosx-kyc-tab',
templateUrl: './kyc-tab.component.html',
styleUrls: ['./kyc-tab.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
MatIcon,
MatProgressSpinner,
MatTooltip,
FaIconComponent
]
})
export class KycTabComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private clientsService = inject(ClientsService);
private formBuilder = inject(UntypedFormBuilder);
private dateUtils = inject(Dates);
private dialog = inject(MatDialog);
private destroy$ = new Subject<void>();
clientId: string;
kycId: string | null = null;
kycData: any = null;
clientData: any = null;
clientDateOfBirth: string | null = null;
isNonIndividual = false;
loading = true;
saving = false;
isEditMode = false;
errorMessage: string | null = null;
/** Re-KYC flow state */
reKycUploading = false;
reKycCompleting = false;
reKycSuccess = false;
reKycError: string | null = null;
selectedDeclarationFile: File | null = null;
verifying: { pan: boolean; aadhaar: boolean; gstin: boolean } = {
pan: false,
aadhaar: false,
gstin: false
};
/** Verification status tracking for re-verify */
verificationStatus: { pan: string | null; aadhaar: string | null; gstin: string | null } = {
pan: null,
aadhaar: null,
gstin: null
};
verificationError: { pan: string | null; aadhaar: string | null; gstin: string | null } = {
pan: null,
aadhaar: null,
gstin: null
};
verificationDetails: { pan: any; aadhaar: any; gstin: any } = {
pan: null,
aadhaar: null,
gstin: null
};
/** Aadhaar OTP flow state */
aadhaarOtpSent = false;
aadhaarOtpReferenceId: string | null = null;
aadhaarOtp = '';
aadhaarOtpVerifying = false;
showAadhaarReVerify = false;
aadhaarFullNumber = '';
kycForm: UntypedFormGroup = this.formBuilder.group({
kycType: [
1,
[Validators.required]
],
riskCategory: [
1,
[Validators.required]
],
// Risk profile. Determinations about the customer, which the periodic risk review reads directly - a PEP or a
// sanctions match holds the rating at High whatever the period's activity looks like.
isPep: [false],
sanctionsMatch: [false],
adverseMediaMatch: [false],
declaredAnnualIncome: [null],
declaredAnnualTurnover: [null],
// The officially valid document, so a lapsed one can be re-collected on this form. The
// backend has always accepted these four; only the fields were missing.
kycDocumentName: [''],
kycDocumentNumber: [''],
kycDocIssueDate: [null],
kycDocExpiryDate: [null],
panNumber: [
'',
[Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
],
aadhaarNumber: [
'',
[Validators.pattern(/^\d{12}$/)]
],
gstinNumber: [
'',
[Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)]
],
remarks: [''],
isException: [false],
exceptionReason: [[]]
});
// ── Officially valid document scans ────────────────────────────────────────
/**
* The scans of the document named above, by side.
*
* The screen used to show the document's number and dates but not the image behind them, and
* sent the officer to the client edit screen to change it. Re-collecting an expiring document is
* the one thing this panel exists for, so the scan is shown and replaced here.
*/
docScans: { front: DocScanSide | null; back: DocScanSide | null } = { front: null, back: null };
/** The two sides, in display order, so the template loops rather than repeating itself. */
readonly docScanSlots: ('front' | 'back')[] = ['front', 'back'];
docScanOf(slot: 'front' | 'back') {
return this.docScans[slot];
}
/** Identifier row replacements are filed against. Null when the document has no identifier. */
private docIdentifierId: string | null = null;
/** Every identifier row carrying this document, since the sides can be split across them. */
private docIdentifierIds: string[] = [];
docScansLoading = false;
/** Which side is mid-upload, so only that button shows a spinner. */
docScanUploading: 'front' | 'back' | null = null;
docScanError: string | null = null;
docScanSuccess: string | null = null;
ngOnInit(): void {
this.route.parent.params.subscribe((params: any) => {
this.clientId = params['clientId'];
// Scans are matched against the document number on the KYC record, so they are loaded once
// that record has arrived rather than alongside it.
this.loadKycData();
this.loadClientDateOfBirth();
});
}
loadKycData(): void {
this.loading = true;
this.errorMessage = null;
this.clientsService
.getClientKyc(this.clientId)
.pipe(
catchError(() => {
this.loading = false;
return of(null);
})
)
.subscribe((data: any) => {
this.loading = false;
this.kycData = data;
if (data) {
const rawKycId = data.id ?? data.kycId ?? data.resourceId;
this.kycId = rawKycId != null ? rawKycId.toString() : null;
this.kycForm.patchValue({
kycType: data.kycType || 1,
riskCategory: data.riskCategory || 1,
isPep: !!data.isPep,
sanctionsMatch: !!data.sanctionsMatch,
adverseMediaMatch: !!data.adverseMediaMatch,
declaredAnnualIncome: data.declaredAnnualIncome ?? null,
declaredAnnualTurnover: data.declaredAnnualTurnover ?? null,
panNumber: data.panNumber || '',
// Aadhaar is stored masked on the server; user must re-enter
// the full 12-digit number to re-verify, so don't pre-fill.
aadhaarNumber: '',
gstinNumber: data.gstinNumber || '',
kycDocumentName: data.kycDocumentName || '',
kycDocumentNumber: data.kycDocumentNumber || '',
kycDocIssueDate: this.parseApiDate(data.kycDocIssueDate),
kycDocExpiryDate: this.parseApiDate(data.kycDocExpiryDate),
remarks: data.remarks || '',
isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
});
// Now that the document number is known, the scans filed under it can be found.
this.loadDocumentScans();
}
});
}
/** Load client's date of birth and legal form from parent route resolved data */
loadClientDateOfBirth(): void {
this.route.parent.data.subscribe((data: any) => {
this.clientData = data?.clientViewData || null;
if (data?.clientViewData?.dateOfBirth) {
const dob = data.clientViewData.dateOfBirth;
if (Array.isArray(dob) && dob.length >= 3) {
this.clientDateOfBirth = `${dob[0]}-${String(dob[1]).padStart(2, '0')}-${String(dob[2]).padStart(2, '0')}`;
} else if (typeof dob === 'string') {
this.clientDateOfBirth = dob;
}
}
if (data?.clientViewData?.legalForm?.id === 2) {
this.isNonIndividual = true;
}
});
}
toggleEditMode(): void {
this.isEditMode = !this.isEditMode;
this.resetOtpState();
if (this.isEditMode) {
// Clear Aadhaar so the user types a fresh 12-digit number for re-verify;
// server only returns the masked form which would fail validation.
this.kycForm.get('aadhaarNumber')?.reset('');
this.verificationStatus.aadhaar = null;
this.verificationStatus.pan = null;
this.verificationError.aadhaar = null;
this.verificationError.pan = null;
}
}
/** True when the stored record carries a signed declaration standing in for the PAN check. */
get panDeclarationTaken(): boolean {
return this.kycData?.panDeclarationTaken === true;
}
/**
* The entity's PAN, for the Enterprise PAN row a non-individual gets in place of the PAN row.
*
* Falls back to panNumber because that is where the KYC step actually stores it — the entity PAN
* is written to both fields on create, but a record saved before `enterprisePan` existed, or one
* saved from a screen that only writes panNumber, would otherwise show no PAN at all.
*/
get enterprisePanNumber(): string {
return this.kycData?.enterprisePan || this.kycData?.panNumber || '';
}
/** "Name", "Date of Birth", "Name and Date of Birth", or the not-checked-at-all case. */
get panDeclarationMismatchLabel(): string {
const fields: string = this.kycData?.panDeclarationMismatchFields;
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()`.
*
* A field counts as verified if it was verified in this session OR is already verified on the
* stored record; either way the customer has cleared that check. Verifying one of two
* outstanding items narrows the exception to the remaining one rather than closing it.
*/
/**
* The officially valid document, for the update payload.
*
* Only sent when a document is named, so an officer editing the risk category on a client who
* never had an OVD recorded does not silently blank the other three columns.
*/
private buildDocumentFields() {
const name = (this.kycForm.value.kycDocumentName || '').trim();
if (!name) {
return {};
}
const dateFormat = 'dd MMMM yyyy';
return {
kycDocumentName: name,
kycDocumentNumber: (this.kycForm.value.kycDocumentNumber || '').trim() || null,
kycDocIssueDate: this.kycForm.value.kycDocIssueDate
? this.dateUtils.formatDate(this.kycForm.value.kycDocIssueDate, dateFormat)
: null,
// Blank is meaningful: a passport expires, a PAN card does not.
kycDocExpiryDate: this.kycForm.value.kycDocExpiryDate
? this.dateUtils.formatDate(this.kycForm.value.kycDocExpiryDate, dateFormat)
: null
};
}
/** True while the banner is asking for a document, so the form shows the fields to supply it. */
get kycDocumentNeedsUpdate(): boolean {
return this.showKycDocumentAlert;
}
/** Today, as the latest an existing document can have been issued. */
get maxDate(): Date {
return new Date();
}
/**
* The customer's risk profile, as every save has to carry it.
*
* <p>
* Sent from every path that writes the KYC record rather than only the main Save, because the form holds one set of
* values: a save triggered from the PAN declaration or the Aadhaar receipt would otherwise write the record without
* these and silently clear a PEP flag the officer had just ticked.
*/
private buildRiskProfileFields() {
const v = this.kycForm.value;
return {
isPep: !!v.isPep,
sanctionsMatch: !!v.sanctionsMatch,
adverseMediaMatch: !!v.adverseMediaMatch,
// Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a
// customer who declared no income at all.
declaredAnnualIncome: v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
declaredAnnualTurnover:
v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover
};
}
private buildExceptionFields() {
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
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,
manualReasons: Array.isArray(this.kycForm.value.exceptionReason) ? this.kycForm.value.exceptionReason : [],
todayFormatted: this.dateUtils.formatDate(new Date(), 'dd MMMM yyyy')
});
}
saveKyc(): void {
if (this.kycForm.invalid) {
return;
}
this.saving = true;
this.errorMessage = null;
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : aadhaarRaw || null;
const payload: any = {
locale: 'en',
panNumber: this.kycForm.value.panNumber || null,
aadhaarNumberMasked: aadhaarMasked,
gstinNumber: this.kycForm.value.gstinNumber || null,
remarks: this.kycForm.value.remarks || null,
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
dateFormat: 'dd MMMM yyyy',
...this.buildDocumentFields(),
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
};
if (!this.kycId) {
payload.kycType = this.kycForm.value.kycType || 1;
payload.riskCategory = this.kycForm.value.riskCategory || 1;
} else {
payload.riskCategory = this.kycForm.value.riskCategory || 1;
}
const save$ = this.kycId
? this.clientsService.updateClientKyc(this.clientId, this.kycId, payload)
: this.clientsService.saveClientKyc(this.clientId, payload);
save$.subscribe({
next: (response: any) => {
this.saving = false;
this.isEditMode = false;
if (!this.kycId && response?.resourceId) {
this.kycId = response.resourceId.toString();
}
this.loadKycData();
},
error: (err: any) => {
this.saving = false;
this.errorMessage =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to save KYC details.';
}
});
}
private saveKycBeforeVerify(callback: () => void, field: 'pan' | 'aadhaar' | 'gstin'): void {
if (this.isEditMode) {
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : aadhaarRaw || null;
const payload: any = {
locale: 'en',
panNumber: this.kycForm.value.panNumber || null,
aadhaarNumberMasked: aadhaarMasked,
gstinNumber: this.kycForm.value.gstinNumber || null,
remarks: this.kycForm.value.remarks || null,
riskCategory: this.kycForm.value.riskCategory || 1,
dateFormat: 'dd MMMM yyyy',
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
};
this.clientsService.updateClientKyc(this.clientId, this.kycId, payload).subscribe({
next: () => {
this.loadKycData();
callback();
},
error: (err: any) => {
this.verifying[field] = false;
this.verificationStatus[field] = 'FAILED';
this.verificationError[field] =
err?.error?.defaultUserMessage || `Failed to update ${field.toUpperCase()} number before verification.`;
}
});
} else {
callback();
}
}
verifyPAN(): void {
if (!this.kycId) {
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
return;
}
this.verifying.pan = true;
this.verificationStatus.pan = 'PENDING';
this.verificationError.pan = null;
this.verificationDetails.pan = null;
this.saveKycBeforeVerify(() => {
this.clientsService
.verifyPAN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.pan = false;
if (response?.changes?.panVerified) {
this.verificationStatus.pan = 'VERIFIED';
this.verificationDetails.pan = response.changes;
// A PAN that now verifies makes any earlier declaration moot. That write already
// re-derives the exception columns, so it settles the exception too.
if (this.panDeclarationTaken) {
this.writePanDeclaration(null);
} else {
this.syncExceptionAfterVerify();
}
} else {
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan = 'PAN verification failed.';
}
},
error: (err: any) => {
this.verifying.pan = false;
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'PAN verification failed. Please try again.';
}
});
}, '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.buildRiskProfileFields(),
...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.';
}
});
}
/**
* Re-derives the exception columns once a verification has actually passed.
*
* `saveKycBeforeVerify()` writes the exception state *before* the vendor is called, when the
* check is by definition still outstanding — so a check that then succeeds leaves the record
* carrying an exception nobody closes. The KYC tab reads `panVerified` and shows "Verified"
* while the exceptions register keeps reading `isException`, which is why a client could sit in
* the Pending tab with every document verified.
*
* Called on each verification success. `buildExceptionFields()` already prefers the in-session
* `verificationStatus` over the stored record, so the freshly passed check counts even though
* `kycData` has not been reloaded yet; `hadException` still reads the stored value, which is
* what tells it to close the entry rather than leave every flag off.
*
* Verifying one of two outstanding items narrows the exception instead of closing it — that
* falls out of the derivation, so this needs no special case for it.
*/
private syncExceptionAfterVerify(): void {
if (!this.kycId) {
this.loadKycData();
return;
}
const exceptionFields = this.buildExceptionFields();
// Nothing recorded and nothing outstanding — no write would change anything.
if (this.kycData?.isException !== true && !exceptionFields.isException) {
this.loadKycData();
return;
}
this.clientsService
.updateClientKyc(this.clientId, this.kycId, {
locale: 'en',
dateFormat: 'dd MMMM yyyy',
riskCategory: this.kycForm.value.riskCategory || 1,
...exceptionFields
})
.pipe(takeUntil(this.destroy$))
.subscribe({
// The verification itself succeeded either way, so a failure here must not read as a
// failed check — surface it as the save problem it is and still refresh.
next: () => this.loadKycData(),
error: (err: any) => {
this.errorMessage =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Verification succeeded but the KYC exception could not be updated.';
this.loadKycData();
}
});
}
/**
* 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.buildRiskProfileFields(),
...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;
this.aadhaarFullNumber = '';
this.verificationError.aadhaar = null;
}
/** Abandons the OTP exchange, so a mis-click can be backed out of without reloading the tab. */
cancelAadhaarReVerify(): void {
this.resetOtpState();
this.verificationError.aadhaar = null;
}
/** Step 1: Send OTP to Aadhaar-linked mobile */
sendAadhaarOtp(): void {
const aadhaar = this.isEditMode ? this.kycForm?.get('aadhaarNumber')?.value : this.aadhaarFullNumber;
if (!aadhaar || aadhaar.length !== 12) {
this.verificationError.aadhaar = 'Please enter the full 12-digit Aadhaar number to generate OTP.';
return;
}
if (!this.kycId) {
this.verificationError.aadhaar = 'KYC record not found. Save KYC details first.';
return;
}
this.verifying.aadhaar = true;
this.verificationStatus.aadhaar = 'PENDING';
this.verificationError.aadhaar = null;
this.aadhaarOtpSent = false;
this.aadhaarOtp = '';
this.saveKycBeforeVerify(() => {
this.clientsService
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.aadhaar = false;
const refNumber =
response?.changes?.aadhaarRefNumber ||
response?.aadhaarRefNumber ||
response?.changes?.referenceNumber ||
response?.resourceId;
this.aadhaarOtpSent = true;
this.aadhaarOtpReferenceId = refNumber || null;
this.verificationStatus.aadhaar = 'PENDING';
},
error: (err: any) => {
this.verifying.aadhaar = false;
this.verificationStatus.aadhaar = 'FAILED';
this.verificationError.aadhaar =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to send OTP. Please try again.';
}
});
}, 'aadhaar');
}
/** Step 2: Verify OTP entered by user */
verifyAadhaarOtp(): void {
if (!this.kycId || !this.aadhaarOtp || this.aadhaarOtp.length < 4) {
return;
}
this.aadhaarOtpVerifying = true;
this.verificationError.aadhaar = null;
this.clientsService
.verifyAadhaarOtp(this.clientId, this.kycId, this.aadhaarOtp, this.aadhaarOtpReferenceId || '')
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.aadhaarOtpVerifying = false;
if (response?.changes?.aadhaarVerified) {
this.verificationStatus.aadhaar = 'VERIFIED';
this.verificationDetails.aadhaar = response.changes;
this.resetOtpState();
// 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.syncExceptionAfterVerify();
}
} else {
this.verificationStatus.aadhaar = 'FAILED';
this.verificationError.aadhaar = 'OTP verification failed.';
this.resetOtpState();
}
},
error: (err: any) => {
this.aadhaarOtpVerifying = false;
this.verificationStatus.aadhaar = 'FAILED';
this.verificationError.aadhaar =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'OTP verification failed. Please try again.';
this.resetOtpState();
}
});
}
verifyGSTIN(): void {
if (!this.kycId) {
this.verificationError.gstin = 'KYC record not found. Save KYC details first.';
return;
}
this.verifying.gstin = true;
this.verificationStatus.gstin = 'PENDING';
this.verificationError.gstin = null;
this.verificationDetails.gstin = null;
this.saveKycBeforeVerify(() => {
this.clientsService
.verifyGSTIN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.gstin = false;
if (response?.changes?.gstinVerified) {
this.verificationStatus.gstin = 'VERIFIED';
this.verificationDetails.gstin = response.changes;
this.loadKycData();
} else {
this.verificationStatus.gstin = 'FAILED';
this.verificationError.gstin = 'GSTIN verification failed.';
}
},
error: (err: any) => {
this.verifying.gstin = false;
this.verificationStatus.gstin = 'FAILED';
this.verificationError.gstin =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'GSTIN verification failed. Please try again.';
}
});
}, 'gstin');
}
formatDate(date: any): string {
if (!date) return '—';
let year: number, month: number, day: number;
if (Array.isArray(date) && date.length >= 3) {
[
year,
month,
day
] = date;
} else {
return date;
}
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
];
return `${day} ${months[month - 1]} ${year}`;
}
private resetOtpState(): void {
this.aadhaarOtpSent = false;
this.aadhaarOtp = '';
this.aadhaarOtpReferenceId = null;
this.aadhaarOtpVerifying = false;
this.showAadhaarReVerify = false;
this.aadhaarFullNumber = '';
}
getStatusLabel(code: string): string {
if (!code) return '';
const part = code.includes('.') ? code.split('.').pop() : code;
return (part.charAt(0).toUpperCase() + part.slice(1)).replace(/_/g, ' ');
}
getStatusClass(code: string): string {
if (!code) return '';
const part = code.includes('.') ? code.split('.').pop() : code;
return part.toLowerCase();
}
getPanAadhaarStatusLabel(status: string): string {
switch (status) {
case 'Y':
return 'Linked (Seeded)';
case 'N':
return 'Not Linked';
case 'E':
return 'Exempt';
default:
return 'Pending Verification';
}
}
/** Generate Self Declaration PDF with KYC details */
printSelfDeclaration(): void {
const client = this.clientData || {};
const kyc = this.kycData || {};
const clientName = client.displayName || client.fullname || '';
const cifNumber = client.accountNo || client.cifNumber || this.clientId;
const dob = this.clientDateOfBirth ? this.formatDate(client.dateOfBirth) : '—';
const officeName = client.officeName || '';
const today = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'long', year: 'numeric' });
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const pageWidth = doc.internal.pageSize.getWidth();
const margin = 20;
const contentWidth = pageWidth - margin * 2;
let y = 20;
// Helper
const addLine = (label: string, value: string, yPos: number): number => {
doc.setFont('helvetica', 'bold');
doc.setFontSize(10);
doc.text(label, margin, yPos);
doc.setFont('helvetica', 'normal');
doc.text(value || '—', margin + 55, yPos);
return yPos + 7;
};
const drawHr = (yPos: number): number => {
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.3);
doc.line(margin, yPos, pageWidth - margin, yPos);
return yPos + 5;
};
// ── Header ──
doc.setFillColor(15, 23, 42);
doc.rect(0, 0, pageWidth, 30, 'F');
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold');
doc.setFontSize(16);
doc.text('Self Declaration Form', pageWidth / 2, 13, { align: 'center' });
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.text('KYC / Re-KYC Compliance — As per RBI Master Direction on KYC', pageWidth / 2, 21, { align: 'center' });
doc.setTextColor(0, 0, 0);
y = 40;
// ── Branch / Date ──
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text(`Branch: ${officeName}`, margin, y);
doc.text(`Date: ${today}`, pageWidth - margin, y, { align: 'right' });
y += 8;
y = drawHr(y);
// ── Section: Customer Details ──
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(16, 116, 185);
doc.text('Customer Details', margin, y);
doc.setTextColor(0, 0, 0);
y += 8;
y = addLine('Customer Name:', clientName, y);
y = addLine('CIF Number:', cifNumber, y);
y = addLine('Date of Birth:', dob, y);
if (client.mobileNo) y = addLine('Mobile:', client.mobileNo, y);
if (client.emailAddress) y = addLine('Email:', client.emailAddress, y);
y += 3;
y = drawHr(y);
// ── Section: KYC Documents ──
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(16, 116, 185);
doc.text('KYC Document Details', margin, y);
doc.setTextColor(0, 0, 0);
y += 8;
// PAN
y = addLine('PAN Number:', kyc.panNumber || '—', 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);
// 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 =
kyc.panAadhaarSeedingStatus === 'Y'
? 'Linked'
: kyc.panAadhaarSeedingStatus === 'N'
? 'Not Linked'
: kyc.panAadhaarSeedingStatus === 'E'
? 'Exempt'
: 'Pending';
y = addLine('PAN-Aadhaar Link:', seedingStatus, y);
// GSTIN (Non-Individual)
if (this.isNonIndividual && kyc.gstinNumber) {
y = addLine('GSTIN:', kyc.gstinNumber, y);
y = addLine('GSTIN Status:', kyc.gstinVerified ? 'Verified' : 'Pending', y);
}
// Enterprise PAN / CIN
if (this.isNonIndividual && kyc.enterprisePan) {
y = addLine('Enterprise PAN:', kyc.enterprisePan, y);
}
if (this.isNonIndividual && kyc.cinNumber) {
y = addLine('CIN:', kyc.cinNumber, y);
}
y += 3;
y = drawHr(y);
// ── Section: KYC Summary ──
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(16, 116, 185);
doc.text('KYC Summary', margin, y);
doc.setTextColor(0, 0, 0);
y += 8;
y = addLine('KYC Type:', kyc.kycType === 1 ? 'Individual' : kyc.kycType === 2 ? 'Non-Individual' : '—', y);
y = addLine(
'Risk Category:',
kyc.riskCategory === 1 ? 'Low' : kyc.riskCategory === 2 ? 'Medium' : kyc.riskCategory === 3 ? 'High' : '—',
y
);
y = addLine('Initiated On:', this.formatDate(kyc.initiatedOn), y);
y = addLine('Last Verified:', this.formatDate(kyc.verifiedOn), y);
y = addLine('Expiry Date:', this.formatDate(kyc.expiryDate), y);
y += 3;
y = drawHr(y);
// ── Declaration Text ──
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(16, 116, 185);
doc.text('Declaration', margin, y);
doc.setTextColor(0, 0, 0);
y += 8;
doc.setFont('helvetica', 'normal');
doc.setFontSize(9.5);
const declarationText = `I, ${clientName || '_______________'}, hereby declare that the information provided above is true, correct and complete to the best of my knowledge and belief. I understand that furnishing false information is punishable under applicable laws. I authorize the Bank to verify the above details through appropriate channels as per RBI Master Direction on KYC (DBR.AML.BC.No.81/14.01.001/2015-16) and its subsequent amendments.
I further confirm that:
1. I am not a Politically Exposed Person (PEP) / relative of a PEP (unless declared otherwise).
2. The source of funds in my account is from legitimate sources.
3. I shall promptly notify the Bank of any change in the above information.
4. I have read and understood the Bank's terms and conditions regarding KYC compliance.`;
const lines = doc.splitTextToSize(declarationText, contentWidth);
doc.text(lines, margin, y);
y += lines.length * 4.5 + 10;
// ── Signature section ──
y = drawHr(y);
y += 5;
// Left: Client signature
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.text('________________________', margin, y + 15);
doc.text('Signature of Customer', margin, y + 21);
doc.text(`Name: ${clientName}`, margin, y + 27);
doc.text(`Date: _______________`, margin, y + 33);
doc.text(`Place: _______________`, margin, y + 39);
// Right: Bank official
doc.text('________________________', pageWidth - margin - 50, y + 15);
doc.text('Authorized Bank Official', pageWidth - margin - 50, y + 21);
doc.text('Name: _______________', pageWidth - margin - 50, y + 27);
doc.text('Designation: _______________', pageWidth - margin - 50, y + 33);
doc.text('Stamp & Seal', pageWidth - margin - 50, y + 39);
// ── Footer ──
const footerY = doc.internal.pageSize.getHeight() - 10;
doc.setFontSize(7);
doc.setTextColor(150, 150, 150);
doc.text('This is a system-generated document. Generated from CBS Nexora — KYC Module.', pageWidth / 2, footerY, {
align: 'center'
});
// Save
doc.save(`Self_Declaration_${cifNumber}_${clientName.replace(/\s/g, '_')}.pdf`);
}
// ── Document scans ─────────────────────────────────────────────────────────
/**
* Finds the identifier the KYC document was filed under and loads its scans.
*
* The KYC record keeps the document's name and number but not the identifier id, so the two are
* matched on the number — that is what makes them the same document, and it survives the client
* holding several identifiers of the same type.
*/
private loadDocumentScans(): void {
if (!this.clientId) {
return;
}
this.docScansLoading = true;
this.clientsService
.getClientIdentifiers(this.clientId)
.pipe(catchError(() => of([] as any[])))
.subscribe((identifiers: any[]) => {
const wantedKey = (this.kycData?.kycDocumentNumber || '').toString().trim();
const wantedName = (this.kycData?.kycDocumentName || '').toString().trim().toLowerCase();
const norm = (s: any) => (s || '').toString().trim().toLowerCase();
// Every identifier carrying this document number, not just the first. The onboarding step
// files each side against its own identifier row, so this client's Aadhaar front sits on
// one row and the back on another under the same number - taking only the first match
// showed the front and reported the back as missing.
let matches = (identifiers || []).filter((i) => wantedKey && norm(i.documentKey) === norm(wantedKey));
if (!matches.length) {
matches = (identifiers || []).filter((i) => wantedName && norm(i.documentType?.name) === wantedName);
}
if (!matches.length) {
this.docScansLoading = false;
this.docIdentifierId = null;
this.docIdentifierIds = [];
this.docScans = { front: null, back: null };
return;
}
// Replacements are filed against the first row, so the sides stay together from here on.
this.docIdentifierId = String(matches[0].id);
this.docIdentifierIds = matches.map((m: any) => String(m.id));
this.fetchScansFor(this.docIdentifierIds);
});
}
/**
* Sides are read off the document name, which is how they were written at upload
* ("<Proof> — Front" / "— Back"). Where the name says nothing, upload order stands in, front
* first, rather than leaving a scan the officer cannot see.
*/
private fetchScansFor(identifierIds: string[]): void {
forkJoin(
identifierIds.map((id) =>
this.clientsService.getClientIdentificationDocuments(id).pipe(
// Tagged with the row it came from, because the download call needs the parent id and
// the sides can be spread across more than one.
map((docs: any) => ((docs as any[]) || []).map((d) => ({ ...d, identifierId: id }))),
catchError(() => of([] as any[]))
)
)
).subscribe((perIdentifier: any[][]) => {
this.docScansLoading = false;
// This runs again after every replace, so the previews being thrown away are revoked
// rather than left holding their blobs.
for (const old of [this.docScans.front, this.docScans.back]) {
if (old?.previewUrl) {
URL.revokeObjectURL(old.previewUrl);
}
}
// Oldest first by document id, so the newest upload for a side is the last one written and
// therefore the one that stands. Identifier order cannot do this job: a replacement is filed
// against the first row while the original sits on the second, so reading the rows in order
// let a document uploaded months ago overwrite the scan collected a moment ago - the officer
// pressed Replace, saw "replaced", and the old image came straight back.
const docs = perIdentifier.flat().sort((a, b) => Number(a.id) - Number(b.id));
const sides: typeof this.docScans = { front: null, back: null };
const unlabelled: any[] = [];
const entryOf = (d: any): DocScanSide => ({
documentId: String(d.id),
identifierId: String(d.identifierId),
fileName: d.fileName || d.name,
previewUrl: null
});
for (const d of docs) {
const text = `${d.name || ''} ${d.fileName || ''}`.toLowerCase();
// Later uploads win, so a replaced side shows the new scan rather than the original.
if (text.includes('back')) {
sides.back = entryOf(d);
} else if (text.includes('front')) {
sides.front = entryOf(d);
} else {
unlabelled.push(d);
}
}
for (const d of unlabelled) {
if (!sides.front) {
sides.front = entryOf(d);
} else if (!sides.back) {
sides.back = entryOf(d);
}
}
this.docScans = sides;
for (const key of ['front', 'back'] as const) {
if (sides[key]) {
this.loadScanPreview(sides[key]!);
}
}
});
}
private loadScanPreview(side: DocScanSide): void {
this.clientsService
.downloadClientIdentificationDocument(side.identifierId, side.documentId)
.pipe(catchError(() => of(null)))
.subscribe((blob: Blob | null) => {
if (!blob || !blob.type?.startsWith('image/')) {
return;
}
// Revoked in ngOnDestroy; a preview left behind holds the blob in memory for the life of
// the tab.
side.previewUrl = URL.createObjectURL(blob);
});
}
/**
* Replaces one side with a freshly collected scan.
*
* The new file is uploaded under the same side name, which is what the readers here and on the
* client screen key on, so the latest upload becomes the scan on show. The old file is left in
* place deliberately: a KYC document that has been re-collected is a record of what was held
* before and when it changed, and destroying it would leave the audit trail with a gap.
*/
onDocScanSelected(event: Event, slot: 'front' | 'back'): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
// Cleared so choosing the same filename twice still fires a change event.
input.value = '';
if (!file || !this.docIdentifierId) {
return;
}
this.docScanError = null;
this.docScanSuccess = null;
this.docScanUploading = slot;
const label = slot === 'front' ? 'Front' : 'Back';
const docName = this.kycForm.get('kycDocumentName')?.value || this.kycData?.kycDocumentName || 'KYC document';
const formData = new FormData();
formData.append('file', file);
formData.append('name', `${docName} — ${label}`);
formData.append('description', `Re-collected on ${new Date().toLocaleDateString('en-IN')}`);
this.clientsService.uploadClientIdentifierDocument(this.docIdentifierId, formData).subscribe({
next: () => {
this.docScanUploading = null;
this.docScanSuccess = `${label} side replaced.`;
// Re-read every row, not just the one written to: the other side still lives on its own.
this.fetchScansFor(this.docIdentifierIds);
},
error: (err: any) => {
this.docScanUploading = null;
this.docScanError =
err?.error?.defaultUserMessage || err?.error?.developerMessage || `Could not replace the ${label.toLowerCase()} side.`;
}
});
}
/**
* A verified record only accepts an amendment with a reason on it, so the dialog has to ask for
* one. 300 is VERIFIED; the code form is checked too, since either can arrive on the payload.
*/
private get isKycVerified(): boolean {
return this.kycData?.kycStatus === 300 || this.kycData?.kycStatusCode === 'clientKycStatus.verified';
}
/**
* Re-collects the officially valid document from its own row.
*
* PAN and Aadhaar are each handled on their row; this one sent the officer into edit mode on the
* whole KYC form to do the same job. The dialog asks for the number, the dates and each side of
* the scan together, which is what re-collecting a document actually is.
*/
updateKycDocument(): void {
if (!this.kycId) {
this.docScanError = 'KYC record not found. Save KYC details first.';
return;
}
this.dialog
.open(KycDocumentUpdateDialogComponent, {
width: '640px',
maxWidth: '95vw',
disableClose: true,
data: {
documentName: this.kycData?.kycDocumentName || 'KYC document',
documentNumber: this.kycData?.kycDocumentNumber || '',
issueDate: this.parseApiDate(this.kycData?.kycDocIssueDate),
expiryDate: this.parseApiDate(this.kycData?.kycDocExpiryDate),
reason: this.showKycDocumentAlert ? this.kycDocumentAlertMessage : '',
currentFront: this.docScans.front?.previewUrl || null,
currentBack: this.docScans.back?.previewUrl || null,
requiresRemarks: this.isKycVerified
} as KycDocumentUpdateData
})
.afterClosed()
.subscribe((result: KycDocumentUpdateResult | undefined) => {
if (result) {
this.persistKycDocument(result);
}
});
}
/**
* Saves the corrected details, then uploads whichever sides were re-scanned.
*
* The details go first: they are the record, and a scan attached to a document whose number was
* never corrected would file the new image under the old identity.
*/
private persistKycDocument(result: KycDocumentUpdateResult): void {
this.saving = true;
this.docScanError = null;
this.docScanSuccess = null;
const dateFormat = 'dd MMMM yyyy';
const payload: any = {
locale: 'en',
dateFormat,
kycDocumentName: this.kycData?.kycDocumentName || null,
kycDocumentNumber: result.documentNumber || null,
kycDocIssueDate: result.issueDate ? this.dateUtils.formatDate(result.issueDate, dateFormat) : null,
kycDocExpiryDate: result.expiryDate ? this.dateUtils.formatDate(result.expiryDate, dateFormat) : null
};
// The server refuses an amendment to a verified record without one, and records it as the
// reason the document changed.
if (result.remarks) {
payload.remarks = result.remarks;
}
this.clientsService.updateClientKyc(this.clientId, this.kycId!, payload).subscribe({
next: () => {
this.saving = false;
this.docScanSuccess = 'Document updated.';
this.loadKycData();
this.uploadReplacedSides(result);
},
error: (err: any) => {
this.saving = false;
this.docScanError =
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not update the document.';
}
});
}
/** Sends each re-scanned side through the same upload path the panel below uses. */
private uploadReplacedSides(result: KycDocumentUpdateResult): void {
if (!this.docIdentifierId) {
return;
}
const sides: { slot: 'front' | 'back'; file: File }[] = [];
if (result.front) {
sides.push({ slot: 'front', file: result.front });
}
if (result.back) {
sides.push({ slot: 'back', file: result.back });
}
if (!sides.length) {
return;
}
const docName = this.kycData?.kycDocumentName || 'KYC document';
forkJoin(
sides.map(({ slot, file }) => {
const formData = new FormData();
formData.append('file', file);
formData.append('name', `${docName} — ${slot === 'front' ? 'Front' : 'Back'}`);
formData.append('description', `Re-collected on ${new Date().toLocaleDateString('en-IN')}`);
return this.clientsService
.uploadClientIdentifierDocument(this.docIdentifierId!, formData)
.pipe(catchError(() => of(null)));
})
).subscribe(() => {
this.docScanSuccess = 'Document and scan updated.';
this.fetchScansFor(this.docIdentifierIds);
});
}
/** Handle file selection for signed declaration upload */
onDeclarationFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
if (input.files?.length) {
this.selectedDeclarationFile = input.files[0];
this.reKycError = null;
}
}
/** Upload signed declaration and complete re-KYC */
completeReKyc(): void {
if (!this.selectedDeclarationFile || !this.clientId || !this.kycId) return;
this.reKycCompleting = true;
this.reKycError = null;
this.reKycSuccess = false;
// Step 1: Upload the signed declaration as a client document
const formData = new FormData();
formData.append('file', this.selectedDeclarationFile);
formData.append('name', 'KYC Self Declaration');
formData.append(
'description',
`Signed self-declaration form uploaded on ${new Date().toLocaleDateString('en-IN')}`
);
this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({
next: () => {
// Step 2: Call re-KYC completion API to update verified/expiry dates
this.clientsService.completeReKyc(this.clientId, this.kycId!).subscribe({
next: () => {
this.reKycCompleting = false;
this.reKycSuccess = true;
this.selectedDeclarationFile = null;
this.loadKycData(); // Refresh to show updated dates
},
error: (err: any) => {
this.reKycCompleting = false;
this.reKycError =
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
'Failed to complete re-KYC. Please try again.';
}
});
},
error: (err: any) => {
this.reKycCompleting = false;
this.reKycError =
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
'Failed to upload declaration. Please try again.';
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
// Object URLs are held by the browser until revoked, so the scan blobs would outlive the tab.
for (const side of [this.docScans.front, this.docScans.back]) {
if (side?.previewUrl) {
URL.revokeObjectURL(side.previewUrl);
}
}
}
}