/** * 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; /** Set when the stored file could not be fetched - the row exists but its content is gone. */ previewUnavailable?: boolean; /** Stored MIME type, so the card knows whether it can render an inline thumbnail. */ mimeType?: string; } type ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF'; type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE'; /** * Client-side upload guards, kept deliberately in step with the backend so a file that passes here * is not rejected server-side: * - 5 MB matches `spring.servlet.multipart.max-file-size` * - the extension list is the intersection of the backend `fineract.content.regex-whitelist` * with what these cards can actually render. * The dialog's `accept=` is only a file-picker hint and is trivially bypassed, so the real check * runs here, on the file the dialog hands back, before anything is sent. */ const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; const IMAGE_EXTENSIONS = [ 'png', 'jpeg', 'jpg' ]; const DOCUMENT_EXTENSIONS = [ ...IMAGE_EXTENSIONS, 'pdf' ]; /** Cards that render an inline , so a PDF would not display in them. */ const IMAGE_ONLY_KINDS: CkycDocKind[] = [ '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']; // Angular reuses this component when only the clientId in the route changes, and // loadExistingArtefacts() only ever *assigns* what it finds - it has no "not found" branch. // Without clearing first, a client with no photo (or no signature, or no proof) keeps showing // the previous client's card. this.resetArtefactState(); this.loadClientInfo(); this.loadAccounts(); this.loadCkycData(); this.loadExistingArtefacts(); }); } /** * Returns every per-client card to its empty state, so nothing from the client just viewed can * survive into the next one. Covers all four cards plus the details staged for an upload - a file * or document number left over from another client must never be submitted against this one. */ private resetArtefactState(): void { this.revokePreviewUrls(); this.existing = { ADDRESS_PROOF: { present: false }, PHOTO_PROOF: { present: false }, CLIENT_IMAGE: { present: false }, CLIENT_SIGNATURE: { present: false } }; this.selectedProofType = { ADDRESS_PROOF: null, PHOTO_PROOF: null }; this.docFiles = { ADDRESS_PROOF: { front: null, back: null }, PHOTO_PROOF: { front: null, back: null } }; this.documentDetails = { ADDRESS_PROOF: { documentKey: '', issueDate: null, expiryDate: null }, PHOTO_PROOF: { documentKey: '', issueDate: null, expiryDate: null } }; this.clientImageFile = null; this.clientSignatureFile = null; this.uploadErrors = { ADDRESS_PROOF: null, PHOTO_PROOF: null, CLIENT_IMAGE: null, CLIENT_SIGNATURE: null }; } /** Frees the blob URLs held by the current cards before their references are dropped. */ private revokePreviewUrls(): void { Object.values(this.existing).forEach((e) => { if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl); if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack); }); } 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, mimeType: signatureDoc.type }; // Same endpoint getClientSignatureImage() uses, but opted out of the global error alert: // a m_document row whose file is no longer in the content store returns 500, and the card // below reports that itself. Without `silent` the interceptor pops an "Internal Server // Error" modal on every page load, for a failure the user cannot act on. this.clientsService .downloadClientDocument(this.clientId, String(signatureDoc.id), true) .pipe(takeUntil(this.destroy$)) .subscribe({ next: (blob: any) => { if (blob && blob.size > 0) { this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob); } else { // A 200 with an empty body still means there is nothing to show. Without this the // card would claim "Already available" with no thumbnail and no explanation. this.existing.CLIENT_SIGNATURE.previewUnavailable = true; } }, error: () => { this.existing.CLIENT_SIGNATURE.previewUnavailable = true; } }); } // 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; this.existing[kind].previewUnavailable = false; 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]; // A proof can be scanned to PDF, which no can render - remember what was stored so the // card offers "View document" instead of a broken thumbnail. if (front) this.existing[kind].mimeType = front.type; 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 || blob.size === 0) { this.existing[kind].previewUnavailable = true; return; } const url = URL.createObjectURL(blob); if (slot === 'front') this.existing[kind].previewUrl = url; else this.existing[kind].previewUrlBack = url; }, // The row exists but its file is gone from the content store. Saying so on the card beats // showing "Already available" next to a blank space. error: () => { this.existing[kind].previewUnavailable = true; } }); } 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; // The dialog's `accept=` filter is only a hint to the file picker and is trivially bypassed, // so the size/type guard runs here on what actually came back. Catching it now turns a raw // server rejection into a message on the card. const rejection = this.validationError(result.front, kind) ?? (result.back ? this.validationError(result.back, kind) : null); if (rejection) { this.uploadErrors[kind] = rejection; return; } this.uploadErrors[kind] = null; 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); }); } /** Extensions a given card can both store and render. */ private allowedExtensions(kind: CkycDocKind): string[] { return IMAGE_ONLY_KINDS.includes(kind) ? IMAGE_EXTENSIONS : DOCUMENT_EXTENSIONS; } /** * Rejects a file the backend would refuse (or the card could not render) before it is uploaded, * so the user gets a clear message instead of a raw server error. Returns null when the file is * acceptable. */ private validationError(file: File | null | undefined, kind: CkycDocKind): string | null { if (!file) { return null; } const allowed = this.allowedExtensions(kind); const extension = (file.name.split('.').pop() || '').toLowerCase(); if (!allowed.includes(extension)) { return `Unsupported file type ".${extension}". Allowed: ${allowed.map((e) => e.toUpperCase()).join(', ')}.`; } if (file.size > MAX_UPLOAD_BYTES) { const mb = (file.size / (1024 * 1024)).toFixed(1); return `File is ${mb} MB. The maximum allowed size is 5 MB.`; } if (file.size === 0) { return 'The selected file is empty.'; } return null; } /** * True when the stored file can be shown as an inline thumbnail. A PDF cannot, so its card falls * back to the "View document" button alone rather than rendering a broken image. */ isImage(kind: CkycDocKind): boolean { const entry = this.existing[kind]; // The profile image arrives as a `data:image/...;base64,` URL, which declares its own type - // it has neither a stored MIME type nor a file extension for the checks below to read. if (entry?.previewUrl?.startsWith('data:')) { return entry.previewUrl.startsWith('data:image/'); } if (entry?.mimeType) { return entry.mimeType.startsWith('image/'); } // Older rows can have no stored MIME type - fall back to the file extension. const extension = (entry?.name?.split('.').pop() || '').toLowerCase(); return IMAGE_EXTENSIONS.includes(extension); } /** * Opens the uploaded document full size in a new tab. Uses the blob already fetched for the * thumbnail, so it works for PDFs too and costs no extra request. */ openDocument(kind: CkycDocKind): void { const url = this.existing[kind]?.previewUrl; if (url) { window.open(url, '_blank', 'noopener'); } } /** 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; // The card is gated on `present`; without this a successful upload left it reading // "Not uploaded yet". uploadProofDocument() sets the same flag for the proof cards. this.existing[key].present = true; this.existing[key].source = this.existing[key].source || 'CKYC upload'; 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!); } // The upload that triggered this refresh is what repairs a previously missing file, so the // warning has to be cleared before the re-read - otherwise a fixed card keeps the old badge. this.existing[staleKey].previewUrl = undefined; this.existing[staleKey].previewUrlBack = undefined; this.existing[staleKey].previewUnavailable = false; 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; } else { this.existing.CLIENT_IMAGE.previewUnavailable = true; } }); 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.existing.CLIENT_SIGNATURE.mimeType = doc.type; this.clientsService .downloadClientDocument(this.clientId, String(doc.id), true) .pipe(takeUntil(this.destroy$)) .subscribe({ next: (blob: any) => { if (blob && blob.size > 0) { this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob); } else { this.existing.CLIENT_SIGNATURE.previewUnavailable = true; } }, error: () => { this.existing.CLIENT_SIGNATURE.previewUnavailable = true; } }); }); break; case 'PHOTO_PROOF': case 'ADDRESS_PROOF': this.loadIdentifierDocPreview(documentType, this.existing[documentType].documentId!); break; } } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); this.revokePreviewUrls(); } }