/** * 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(); 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. * *

* 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 * (" — 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); } } } }