Files
cbsnexora/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts
T
anshulandClaude Sonnet 5 cda7c52d11 Merge branch 'dev' into feature/bugfix
Conflicts resolved in:
- src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html
- src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts

Kept dev's CKYC architecture (upload dialog, real per-artefact endpoints)
and layered feature/bugfix's fixes on top: upload file validation,
missing-file badge, silent preview download, and image/PDF view button.
Also includes the CKYC "Image of Client" upload-state fix and the
client-change state-leak fix in the same component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 16:55:32 +05:30

887 lines
35 KiB
TypeScript

/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { 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 <img>, 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<void>();
/** 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<CkycDocKind, ExistingArtefact> = {
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<ProofKind, ProofType | null> = {
ADDRESS_PROOF: null,
PHOTO_PROOF: null
};
/** Front/back files staged for the two proof cards; other cards keep a single file each */
docFiles: Record<ProofKind, { front: File | null; back: File | null }> = {
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<string>((proofTypes?.idProofs || []).map((p: any) => String(p.id)));
const addressProofIds = new Set<string>((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 "<ProofName> - <clientId>"
// 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 "<Proof> — 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 <img> 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<CkycDocKind, string> = {
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<any>;
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<any> = !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<any>[] = [];
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();
}
}