/** * 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 { catchError, of, Subject, takeUntil } from 'rxjs'; /** Custom Services */ import { ClientsService } from '../../clients.service'; import { deriveKycExceptionFields } from '../../kyc-exception.util'; 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'; /** * 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 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] ], 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: [[]] }); ngOnInit(): void { this.route.parent.params.subscribe((params: any) => { this.clientId = params['clientId']; 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, 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 || '', remarks: data.remarks || '', isException: data.isException || false, exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : [] }); } }); } /** 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; } } /** * 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. */ 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, aadhaarVerified, 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.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.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; this.loadKycData(); } 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'); } /** Show Aadhaar input in summary view for re-verification */ startAadhaarReVerify(): void { this.showAadhaarReVerify = true; this.aadhaarFullNumber = ''; 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(); this.loadKycData(); } 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); y = addLine('PAN Status:', kyc.panVerified ? 'Verified' : 'Pending', y); if (kyc.panVerifiedOn) y = addLine('PAN Verified On:', this.formatDate(kyc.panVerifiedOn), y); // Aadhaar y = addLine('Aadhaar Number:', kyc.aadhaarNumberMasked || '—', y); y = addLine('Aadhaar Status:', kyc.aadhaarVerified ? 'Verified' : 'Pending', y); if (kyc.aadhaarVerifiedOn) y = addLine('Aadhaar Verified On:', this.formatDate(kyc.aadhaarVerifiedOn), 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`); } /** 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(); } }