/** * 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 { Subject, Observable, takeUntil, catchError, of, forkJoin } from 'rxjs'; /** Custom Services */ import { ClientsService } from '../../clients.service'; import { AlertService } from 'app/core/alert/alert.service'; import { MatIcon } from '@angular/material/icon'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatDialog } from '@angular/material/dialog'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { Dates } from 'app/core/utils/dates'; import { ProofType, normalizeProofType } from '../../shared/kyc-proof-types'; import { CkycUploadDialogComponent, CkycUploadDialogResult } from './ckyc-upload-dialog/ckyc-upload-dialog.component'; interface ExistingArtefact { present: boolean; name?: string; source?: string; previewUrl?: string; previewUrlBack?: string; documentId?: string; documentTypeId?: string; documentKey?: string; /** "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" — same convention client-india-address-step * already writes/reads for address proof; now shared by the photo/identity proof too. */ description?: string; fileCount?: number; } type ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF'; type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE'; @Component({ selector: 'mifosx-ckyc-tab', templateUrl: './ckyc-tab.component.html', styleUrls: ['./ckyc-tab.component.scss'], standalone: true, imports: [ ...STANDALONE_SHARED_IMPORTS, MatIcon, MatProgressSpinner, FaIconComponent ] }) export class CkycTabComponent implements OnInit, OnDestroy { private route = inject(ActivatedRoute); private clientsService = inject(ClientsService); private alertService = inject(AlertService); private dialog = inject(MatDialog); private dates = inject(Dates); private destroy$ = new Subject(); /** Top section - read-only client info */ clientId: string; clientName: string = ''; clientEmail: string = ''; clientMobile: string = ''; cifNumber: string = ''; ckycNumber: string = ''; allAccounts: any[] = []; selectedAccount: any = null; selectedAccountType: string = ''; /** State */ loading = true; ckycData: any = null; ckycUploadedOn = ''; ckycBatchId = ''; checkingExisting = false; /** KYC verification status (Aadhaar + PAN) — independent of CERSAI sync */ panVerified = false; aadhaarVerified = false; panHolderName = ''; aadhaarHolderName = ''; panNumber = ''; aadhaarMasked = ''; get kycComplete(): boolean { return this.panVerified && this.aadhaarVerified; } /** Pre-existing artefacts detected from client data */ existing: Record = { ADDRESS_PROOF: { present: false }, PHOTO_PROOF: { present: false }, CLIENT_IMAGE: { present: false }, CLIENT_SIGNATURE: { present: false } }; /** Document-type options for the proof pickers, fetched once from /kyc/proof-types */ addressProofOptions: ProofType[] = []; idProofOptions: ProofType[] = []; /** Document type currently selected in each proof card's picker */ selectedProofType: Record = { ADDRESS_PROOF: null, PHOTO_PROOF: null }; /** Front/back files staged for the two proof cards; other cards keep a single file each */ docFiles: Record = { ADDRESS_PROOF: { front: null, back: null }, PHOTO_PROOF: { front: null, back: null } }; /** Document number + issue/expiry dates staged from the upload dialog, same fields client * creation collects for a KYC/address proof — persisted onto the identifier's documentKey * and description ("Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd"). */ private documentDetails: Record< ProofKind, { documentKey: string; issueDate: string | null; expiryDate: string | null } > = { ADDRESS_PROOF: { documentKey: '', issueDate: null, expiryDate: null }, PHOTO_PROOF: { documentKey: '', issueDate: null, expiryDate: null } }; /** File references */ clientImageFile: File | null = null; clientSignatureFile: File | null = null; /** Per-document upload state */ uploading: { [key: string]: boolean } = { ADDRESS_PROOF: false, PHOTO_PROOF: false, CLIENT_IMAGE: false, CLIENT_SIGNATURE: false }; /** Per-document error messages */ uploadErrors: { [key: string]: string | null } = { ADDRESS_PROOF: null, PHOTO_PROOF: null, CLIENT_IMAGE: null, CLIENT_SIGNATURE: null }; ngOnInit(): void { this.route.parent.params.subscribe((params: any) => { this.clientId = params['clientId']; this.loadClientInfo(); this.loadAccounts(); this.loadCkycData(); this.loadExistingArtefacts(); }); } private loadClientInfo(): void { this.route.parent.data.subscribe((data: any) => { const cv = data?.clientViewData; if (cv) { this.clientName = cv.displayName || ''; this.clientEmail = cv.emailAddress || ''; this.clientMobile = cv.mobileNo || ''; this.cifNumber = cv.accountNo || cv.externalId || ''; } }); } private loadAccounts(): void { this.clientsService .getClientAccountData(this.clientId) .pipe( takeUntil(this.destroy$), catchError(() => of(null)) ) .subscribe((data: any) => { if (data) { const savings = (data.savingsAccounts || []).map((a: any) => ({ accountNo: a.accountNo, accountType: 'Savings', productName: a.productName || '', id: a.id })); const loans = (data.loanAccounts || []).map((a: any) => ({ accountNo: a.accountNo, accountType: 'Loan', productName: a.productName || '', id: a.id })); this.allAccounts = [ ...savings, ...loans ]; } }); } private loadCkycData(): void { this.loading = true; // Read CKYC registry data from /clients/{id}/kyc — that's where the // SFTP upload flow persists ckycNumber / ckycUploadedOn / ckycBatchId. this.clientsService .getClientKyc(this.clientId) .pipe( takeUntil(this.destroy$), catchError(() => { this.loading = false; return of(null); }) ) .subscribe((data: any) => { this.loading = false; this.ckycData = data; this.ckycNumber = data?.ckycNumber || ''; this.ckycUploadedOn = this.formatJavaDate(data?.ckycUploadedOn); this.ckycBatchId = data?.ckycBatchId || ''; this.panVerified = data?.panVerified === true || data?.panVerified === 'true' || data?.panVerified === 1; this.aadhaarVerified = data?.aadhaarVerified === true || data?.aadhaarVerified === 'true' || data?.aadhaarVerified === 1; this.panHolderName = data?.panFullName || data?.panHolderName || ''; this.aadhaarHolderName = data?.aadhaarHolderName || ''; this.panNumber = data?.panNumber || ''; this.aadhaarMasked = data?.aadhaarNumberMasked || ''; }); } private formatJavaDate(value: any): string { if (!value) return ''; if (Array.isArray(value) && value.length >= 3) { const [ y, m, d ] = value; return `${String(d).padStart(2, '0')}-${String(m).padStart(2, '0')}-${y}`; } if (typeof value === 'string') return value; return ''; } /** * Fetch photo (profile image), signature (client document), * ID proof and address proof (client identifiers) so already-uploaded * artefacts are shown here instead of forcing re-upload. */ private loadExistingArtefacts(): void { this.checkingExisting = true; forkJoin({ photo: this.clientsService.getClientProfileImage(this.clientId).pipe(catchError(() => of(null))), documents: this.clientsService.getClientDocuments(this.clientId).pipe(catchError(() => of([]))), identifiers: this.clientsService.getClientIdentifiers(this.clientId).pipe(catchError(() => of([]))), proofTypes: this.clientsService.getKycProofTypes().pipe(catchError(() => of({ idProofs: [], addressProofs: [] }))) }) .pipe(takeUntil(this.destroy$)) .subscribe(({ photo, documents, identifiers, proofTypes }) => { this.checkingExisting = false; // 1. Photo / image of client — from m_client.image_id if (photo && typeof photo === 'string' && photo.length > 0) { this.existing.CLIENT_IMAGE = { present: true, source: 'Client registration', previewUrl: photo, name: 'Profile photo' }; } // 2. Signature — client document with name='clientSignature'. Multiple rows can exist // for clients who re-cropped/replaced their signature before uploads became single-slot, // so pick the highest id (latest) rather than the first unordered match. const signatureDoc = (documents as any[]) ?.filter((d: any) => d?.name === 'clientSignature') ?.sort((a: any, b: any) => b.id - a.id)?.[0]; if (signatureDoc) { this.existing.CLIENT_SIGNATURE = { present: true, source: 'Client registration', name: signatureDoc.fileName || 'Client signature', documentId: signatureDoc.id }; // Fetch blob for preview this.clientsService .getClientSignatureImage(this.clientId, signatureDoc.id) .pipe(takeUntil(this.destroy$)) .subscribe({ next: (blob: any) => { if (blob) { this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob); } }, error: () => undefined }); } // 3 & 4. ID proof + Address proof — client identifiers classified via proof-types this.idProofOptions = (proofTypes?.idProofs || []).map((p: any) => normalizeProofType(p)); this.addressProofOptions = (proofTypes?.addressProofs || []).map((p: any) => normalizeProofType(p)); const idProofIds = new Set((proofTypes?.idProofs || []).map((p: any) => String(p.id))); const addressProofIds = new Set((proofTypes?.addressProofs || []).map((p: any) => String(p.id))); const idList = Array.isArray(identifiers) ? identifiers : (identifiers as any)?.pageItems || []; for (const identity of idList) { const docTypeId = String(identity?.documentType?.id ?? ''); const isActive = !identity?.status || identity?.status?.code === 'clientIdentifierStatusType.active' || identity?.status === 'clientIdentifierStatusType.active' || identity?.status === 'Active'; if (!isActive) continue; const proofName = identity?.documentType?.name || 'Identifier'; // Address proof documentKey is auto-generated as " - " // in identities-tab; hide it when it's just that synthetic key. const rawKey = identity?.documentKey || ''; const isSyntheticKey = rawKey.startsWith(`${proofName} - `); const common = { present: true, source: 'Identities section', name: proofName, documentKey: isSyntheticKey ? '' : rawKey, documentId: identity?.id, documentTypeId: docTypeId, description: identity?.description || '', fileCount: (identity?.documents || []).length }; if (idProofIds.has(docTypeId) && !this.existing.PHOTO_PROOF.present) { this.existing.PHOTO_PROOF = common; this.selectedProofType.PHOTO_PROOF = this.idProofOptions.find((p) => String(p.id) === docTypeId) || null; this.loadIdentifierDocPreview('PHOTO_PROOF', identity.id); } else if (addressProofIds.has(docTypeId) && !this.existing.ADDRESS_PROOF.present) { this.existing.ADDRESS_PROOF = common; this.selectedProofType.ADDRESS_PROOF = this.addressProofOptions.find((p) => String(p.id) === docTypeId) || null; this.loadIdentifierDocPreview('ADDRESS_PROOF', identity.id); } } }); } /** * The scanned ID/address-proof card only showed its file name and key — never the scan * itself, unlike the photo and signature cards right beside it. Mirrors those two: fetch * the identifier's own attached document(s) (m_client_identifier -> m_document, the same * chain the Identities tab and the create-client stepper read) and download the latest for * an inline preview. Two-sided proof types (Aadhaar, Passport, Voter ID, Driving Licence) * store front/back as two separate documents named " — Front"/"— Back" (the same * convention the create-client stepper writes), so both are matched and previewed here; * a legacy single upload with no such marker just shows as the one preview. */ private loadIdentifierDocPreview(kind: ProofKind, identifierId: string): void { this.clientsService .getClientIdentificationDocuments(identifierId) .pipe( takeUntil(this.destroy$), catchError(() => of([])) ) .subscribe((docs: any) => { const list = Array.isArray(docs) ? docs : []; if (!list.length) return; this.existing[kind].fileCount = list.length; const byNewest = (a: any, b: any) => b.id - a.id; const label = (d: any) => (d?.name || d?.fileName || '').toString(); const back = [...list].filter((d: any) => /back/i.test(label(d))).sort(byNewest)[0]; const front = [...list].filter((d: any) => /front/i.test(label(d))).sort(byNewest)[0] || [...list].filter((d: any) => d.id !== back?.id).sort(byNewest)[0]; if (front) this.downloadIdentifierDocPreview(identifierId, front.id, kind, 'front'); if (back && back.id !== front?.id) this.downloadIdentifierDocPreview(identifierId, back.id, kind, 'back'); }); } private downloadIdentifierDocPreview( identifierId: string, documentId: string, kind: ProofKind, slot: 'front' | 'back' ): void { this.clientsService .downloadClientIdentificationDocument(identifierId, documentId) .pipe(takeUntil(this.destroy$)) .subscribe({ next: (blob: any) => { if (!blob) return; const url = URL.createObjectURL(blob); if (slot === 'front') this.existing[kind].previewUrl = url; else this.existing[kind].previewUrlBack = url; }, error: () => undefined }); } onAccountSelect(accountNo: string): void { this.selectedAccount = this.allAccounts.find((a) => a.accountNo === accountNo) || null; this.selectedAccountType = this.selectedAccount?.accountType || ''; } getFile(documentType: string): File | null { switch (documentType) { case 'CLIENT_IMAGE': return this.clientImageFile; case 'CLIENT_SIGNATURE': return this.clientSignatureFile; default: return null; } } private static readonly CARD_TITLES: Record = { ADDRESS_PROOF: 'Address Proof Document', PHOTO_PROOF: 'Photo Proof Document', CLIENT_IMAGE: 'Image of Client', CLIENT_SIGNATURE: 'Signature of Client' }; /** Opens the picker/preview popup for one card and, once the user confirms a file there, feeds * the picked type/file(s) into the same upload path the card always used. Picking and preview * now live entirely in the dialog — this screen only stages the result and re-runs upload. */ openUploadDialog(kind: CkycDocKind): void { const isProofKind = kind === 'ADDRESS_PROOF' || kind === 'PHOTO_PROOF'; const action = this.existing[kind].present ? 'Replace' : 'Upload'; const existingDates = isProofKind ? this.parseDescriptionDates(this.existing[kind].description) : { issueDate: null, expiryDate: null }; const ref = this.dialog.open(CkycUploadDialogComponent, { width: '480px', maxWidth: '95vw', data: { title: `${action} ${CkycTabComponent.CARD_TITLES[kind]}`, isProofKind, accept: isProofKind ? '.png,.jpeg,.jpg,.pdf' : '.png,.jpeg,.jpg', proofOptions: kind === 'PHOTO_PROOF' ? this.idProofOptions : kind === 'ADDRESS_PROOF' ? this.addressProofOptions : undefined, initialProofType: isProofKind ? this.selectedProofType[kind as ProofKind] : undefined, initialDocumentKey: isProofKind ? this.existing[kind].documentKey || '' : undefined, initialIssueDate: existingDates.issueDate, initialExpiryDate: existingDates.expiryDate } }); ref.afterClosed().subscribe((result: CkycUploadDialogResult | null) => { if (!result) return; if (isProofKind) { const proofKind = kind as ProofKind; this.selectedProofType[proofKind] = result.proofType || null; this.docFiles[proofKind] = { front: result.front, back: result.back || null }; this.documentDetails[proofKind] = { documentKey: result.documentKey || '', issueDate: result.issueDate || null, expiryDate: result.expiryDate || null }; } else if (kind === 'CLIENT_IMAGE') { this.clientImageFile = result.front; } else { this.clientSignatureFile = result.front; } this.uploadDocument(kind); }); } /** Reads back the "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" convention this tab (and * client-india-address-step, for address proof) writes into the identifier's description. */ private parseDescriptionDates(description: string | undefined): { issueDate: Date | null; expiryDate: Date | null } { const issueMatch = (description || '').match(/Issue date:\s*([^,|]+)/i); const expiryMatch = (description || '').match(/Expiry date:\s*([^,|]+)/i); return { issueDate: issueMatch ? this.dates.parseDate(issueMatch[1].trim()) : null, expiryDate: expiryMatch ? this.dates.parseDate(expiryMatch[1].trim()) : null }; } private buildDescription(issueDate: string | null, expiryDate: string | null): string { const parts: string[] = []; if (issueDate) parts.push(`Issue date: ${issueDate}`); if (expiryDate) parts.push(`Expiry date: ${expiryDate}`); return parts.join(', '); } /** Issue/expiry date for display on the card, formatted dd-MM-yyyy — parsed from the same * description text the upload dialog writes. */ cardDate(kind: ProofKind, which: 'issue' | 'expiry'): string { const parsed = this.parseDescriptionDates(this.existing[kind].description); const value = which === 'issue' ? parsed.issueDate : parsed.expiryDate; return value ? this.dates.formatDate(value, 'dd-MM-yyyy') : ''; } /** * Routes each card to whichever real endpoint that artefact is actually stored through — there * is no single "/clients/{id}/ckyc/documents" resource on the backend, so posting every card to * one made-up URL 404'd for all four. A photo goes to the image endpoint, a signature to the * client's own documents, and an ID/address proof to the m_client_identifier it already belongs * to (the same one the Identities tab and create-client stepper post to) — never a fresh, * type-less upload, since which identifier a proof belongs to is exactly what makes it that * proof rather than some other file on the client's record. */ uploadDocument(documentType: string): void { if (documentType === 'PHOTO_PROOF' || documentType === 'ADDRESS_PROOF') { this.uploadProofDocument(documentType); return; } const file = this.getFile(documentType); if (!file) return; this.uploading[documentType] = true; this.uploadErrors[documentType] = null; const key = documentType as keyof typeof this.existing; let request$: Observable; switch (documentType) { case 'CLIENT_IMAGE': request$ = this.clientsService.uploadClientProfileImage(this.clientId, file); break; case 'CLIENT_SIGNATURE': request$ = this.clientsService.uploadClientSignatureImage(this.clientId, file); break; default: return; } request$.pipe(takeUntil(this.destroy$)).subscribe({ next: () => { this.uploading[documentType] = false; this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' }); this.refreshPreview(documentType); }, error: (err: any) => { this.uploading[documentType] = false; this.uploadErrors[documentType] = this.extractErrorMessage(err); } }); } /** * Proof cards work like the create-client KYC step: pick a document type first (its `sides` * decides whether one image or a front+back pair is required), then upload. Reuses the same * identifier the Identities tab manages — updates its documentTypeId if the picked type * changed, or creates a fresh identifier when none exists yet, so this card never depends on * a separate trip to the Identities tab. */ private uploadProofDocument(kind: ProofKind): void { const proof = this.selectedProofType[kind]; if (!proof) { this.uploadErrors[kind] = 'Select a document type first.'; return; } const files = this.docFiles[kind]; if (!files.front || (proof.sides === 2 && !files.back)) { this.uploadErrors[kind] = proof.sides === 2 ? 'Both front and back images are required for this document type.' : 'Select a file to upload.'; return; } this.uploading[kind] = true; this.uploadErrors[kind] = null; const existingId = this.existing[kind].documentId; const details = this.documentDetails[kind]; const documentKey = details.documentKey || `${proof.name} - ${this.clientId}`; const description = this.buildDescription(details.issueDate, details.expiryDate); const identifier$: Observable = !existingId ? this.clientsService.addClientIdentifier(this.clientId, { documentTypeId: proof.id, status: 'ACTIVE', documentKey, description: description || 'KYC document' }) : this.clientsService.updateClientIdentifier(this.clientId, existingId, { documentTypeId: proof.id, status: 'ACTIVE', documentKey, description }); identifier$.pipe(takeUntil(this.destroy$)).subscribe({ next: (res: any) => { const identifierId = existingId || res?.resourceId; const uploads: Observable[] = []; const front = new FormData(); front.append('name', proof.sides === 2 ? `${proof.name} — Front` : proof.name); front.append('file', files.front!); uploads.push(this.clientsService.uploadClientIdentifierDocument(identifierId, front)); if (proof.sides === 2 && files.back) { const back = new FormData(); back.append('name', `${proof.name} — Back`); back.append('file', files.back); uploads.push(this.clientsService.uploadClientIdentifierDocument(identifierId, back)); } forkJoin(uploads) .pipe(takeUntil(this.destroy$)) .subscribe({ next: () => { this.uploading[kind] = false; this.existing[kind].present = true; this.existing[kind].documentId = identifierId; this.existing[kind].documentTypeId = String(proof.id); this.existing[kind].documentKey = details.documentKey || ''; this.existing[kind].description = description; this.existing[kind].name = proof.name; this.existing[kind].source = this.existing[kind].source || 'Identities section'; this.docFiles[kind] = { front: null, back: null }; this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' }); this.refreshPreview(kind); }, error: (err: any) => { this.uploading[kind] = false; this.uploadErrors[kind] = this.extractErrorMessage(err); } }); }, error: (err: any) => { this.uploading[kind] = false; this.uploadErrors[kind] = this.extractErrorMessage(err); } }); } private extractErrorMessage(err: any): string { return ( err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.developerMessage || err?.error?.defaultUserMessage || err?.message || 'Failed to upload document.' ); } /** Re-fetches whichever artefact was just replaced, so its preview reflects the new file * immediately instead of the one this screen loaded on entry. */ private refreshPreview(documentType: string): void { const staleKey = documentType as keyof typeof this.existing; if (this.existing[staleKey].previewUrl?.startsWith('blob:')) { URL.revokeObjectURL(this.existing[staleKey].previewUrl!); } if (this.existing[staleKey].previewUrlBack?.startsWith('blob:')) { URL.revokeObjectURL(this.existing[staleKey].previewUrlBack!); } switch (documentType) { case 'CLIENT_IMAGE': this.clientsService .getClientProfileImage(this.clientId) .pipe(takeUntil(this.destroy$)) .subscribe((photo: any) => { if (photo) this.existing.CLIENT_IMAGE.previewUrl = photo; }); break; case 'CLIENT_SIGNATURE': this.clientsService .getClientDocuments(this.clientId) .pipe(takeUntil(this.destroy$)) .subscribe((documents: any) => { const doc = (documents as any[]) ?.filter((d: any) => d?.name === 'clientSignature') ?.sort((a: any, b: any) => b.id - a.id)?.[0]; if (!doc) return; this.existing.CLIENT_SIGNATURE.documentId = doc.id; this.clientsService .getClientSignatureImage(this.clientId, doc.id) .pipe(takeUntil(this.destroy$)) .subscribe((blob: any) => { if (blob) this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob); }); }); break; case 'PHOTO_PROOF': case 'ADDRESS_PROOF': this.loadIdentifierDocPreview(documentType, this.existing[documentType].documentId!); break; } } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); // Release object URLs Object.values(this.existing).forEach((e) => { if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl); if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack); }); } }