feat: add CKYC management tab, checker inbox views, and maker-checker diff utilities
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Subject, takeUntil, catchError, of, forkJoin } from 'rxjs';
|
||||
import { Subject, takeUntil, catchError, of } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../../clients.service';
|
||||
@@ -28,8 +28,60 @@ interface ExistingArtefact {
|
||||
documentKey?: string;
|
||||
fileCount?: number;
|
||||
expanded?: boolean;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multipart `name` / `description` sent for each CKYC artefact type.
|
||||
*
|
||||
* The platform has no CKYC-specific document API; these are stored through the generic
|
||||
* DocumentApiResource, whose contract carries no document-type field. The artefact type therefore
|
||||
* travels in `name`, which is the same mechanism the client signature already uses - so
|
||||
* `CLIENT_SIGNATURE` deliberately reuses the existing `clientSignature` value that
|
||||
* loadExistingArtefacts() (and uploadClientSignatureImage()) match on. Changing that string would
|
||||
* orphan every previously uploaded signature.
|
||||
*/
|
||||
export type CkycDocumentType = 'ID_PROOF' | 'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE';
|
||||
|
||||
const CKYC_DOCUMENT_META: Record<CkycDocumentType, { name: string; description: string }> = {
|
||||
ID_PROOF: { name: 'ckycIdProof', description: 'CKYC ID proof' },
|
||||
ADDRESS_PROOF: { name: 'ckycAddressProof', description: 'CKYC address proof' },
|
||||
PHOTO_PROOF: { name: 'ckycPhotoProof', description: 'CKYC photo proof' },
|
||||
CLIENT_IMAGE: { name: 'ckycClientImage', description: 'CKYC client photograph' },
|
||||
CLIENT_SIGNATURE: { name: 'clientSignature', description: 'Client signature' }
|
||||
};
|
||||
|
||||
const CKYC_DOCUMENT_TYPES = Object.keys(CKYC_DOCUMENT_META) as CkycDocumentType[];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* `accept=` on the input is only a file-picker hint and is trivially bypassed, so the real check
|
||||
* lives in onFileSelect().
|
||||
*/
|
||||
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
||||
const IMAGE_EXTENSIONS = [
|
||||
'png',
|
||||
'jpeg',
|
||||
'jpg'
|
||||
];
|
||||
const DOCUMENT_EXTENSIONS = [
|
||||
...IMAGE_EXTENSIONS,
|
||||
'pdf'
|
||||
];
|
||||
|
||||
/** Types whose card renders an inline <img> preview, so a PDF would not display. */
|
||||
const IMAGE_ONLY_TYPES: CkycDocumentType[] = [
|
||||
'CLIENT_IMAGE',
|
||||
'CLIENT_SIGNATURE'
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-ckyc-tab',
|
||||
templateUrl: './ckyc-tab.component.html',
|
||||
@@ -79,8 +131,9 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
return this.panVerified && this.aadhaarVerified;
|
||||
}
|
||||
|
||||
/** Pre-existing artefacts detected from client data */
|
||||
existing: Record<'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE', ExistingArtefact> = {
|
||||
/** CKYC artefacts already uploaded for this client, keyed by artefact type. */
|
||||
existing: Record<CkycDocumentType, ExistingArtefact> = {
|
||||
ID_PROOF: { present: false },
|
||||
ADDRESS_PROOF: { present: false },
|
||||
PHOTO_PROOF: { present: false },
|
||||
CLIENT_IMAGE: { present: false },
|
||||
@@ -88,6 +141,7 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
|
||||
/** File references */
|
||||
idProofFile: File | null = null;
|
||||
addressProofFile: File | null = null;
|
||||
photoProofFile: File | null = null;
|
||||
clientImageFile: File | null = null;
|
||||
@@ -95,6 +149,7 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
|
||||
/** Per-document upload state */
|
||||
uploading: { [key: string]: boolean } = {
|
||||
ID_PROOF: false,
|
||||
ADDRESS_PROOF: false,
|
||||
PHOTO_PROOF: false,
|
||||
CLIENT_IMAGE: false,
|
||||
@@ -106,6 +161,7 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
|
||||
/** Per-document error messages */
|
||||
uploadErrors: { [key: string]: string | null } = {
|
||||
ID_PROOF: null,
|
||||
ADDRESS_PROOF: null,
|
||||
PHOTO_PROOF: null,
|
||||
CLIENT_IMAGE: null,
|
||||
@@ -207,98 +263,110 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Loads every CKYC artefact from the client's documents (`m_document`), which is the single
|
||||
* source of truth for this screen: each card matches the `name` its own upload writes, so what
|
||||
* the checker sees is exactly what CKYC stored.
|
||||
*
|
||||
* Deliberately does NOT read the profile image or client identifiers any more. Those belong to
|
||||
* client registration and the Identities section; reading them here showed artefacts this screen
|
||||
* had never uploaded, and hid the ones it had. Those other screens are untouched.
|
||||
*
|
||||
* A client can accumulate several rows for one artefact (older uploads pre-date replacement, and
|
||||
* the signature is also written by client registration), so the newest row - highest `id` - wins.
|
||||
*/
|
||||
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.clientsService
|
||||
.getClientDocuments(this.clientId)
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
catchError(() => of([]))
|
||||
)
|
||||
.subscribe((documents: any) => {
|
||||
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'
|
||||
};
|
||||
}
|
||||
const list: any[] = Array.isArray(documents) ? documents : (documents?.pageItems ?? []);
|
||||
|
||||
// 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
|
||||
});
|
||||
}
|
||||
for (const type of CKYC_DOCUMENT_TYPES) {
|
||||
const expectedName = CKYC_DOCUMENT_META[type].name;
|
||||
|
||||
// 3 & 4. ID proof + Address proof — client identifiers classified via proof-types
|
||||
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 doc = list
|
||||
.filter((d: any) => d?.name === expectedName)
|
||||
.sort((a: any, b: any) => (b?.id ?? 0) - (a?.id ?? 0))[0];
|
||||
|
||||
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,
|
||||
fileCount: (identity?.documents || []).length
|
||||
};
|
||||
|
||||
if (idProofIds.has(docTypeId) && !this.existing.PHOTO_PROOF.present) {
|
||||
this.existing.PHOTO_PROOF = common;
|
||||
} else if (addressProofIds.has(docTypeId) && !this.existing.ADDRESS_PROOF.present) {
|
||||
this.existing.ADDRESS_PROOF = common;
|
||||
if (!doc) {
|
||||
// No upload yet for this artefact - leave the card in its empty state.
|
||||
this.existing[type] = { present: false };
|
||||
continue;
|
||||
}
|
||||
|
||||
this.existing[type] = {
|
||||
present: true,
|
||||
source: 'CKYC upload',
|
||||
name: doc.fileName || expectedName,
|
||||
documentId: doc.id,
|
||||
mimeType: doc.type
|
||||
};
|
||||
|
||||
this.loadPreview(type, doc.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the stored file so the card can show a thumbnail. A failure here is reported on the
|
||||
* card rather than swallowed: the usual cause is a `m_document` row whose file is no longer in
|
||||
* the content store, and silently showing "Already available" with no image hides that.
|
||||
*/
|
||||
private loadPreview(type: CkycDocumentType, documentId: string | number): void {
|
||||
this.clientsService
|
||||
// silent: a missing stored file returns 500, and the card below already reports that. Without
|
||||
// this the global error interceptor pops an "Internal Server Error" modal on every page load
|
||||
// and after every upload, for a failure the user cannot act on.
|
||||
.downloadClientDocument(this.clientId, String(documentId), true)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (blob: any) => {
|
||||
if (blob && blob.size > 0) {
|
||||
this.existing[type].previewUrl = URL.createObjectURL(blob);
|
||||
} else {
|
||||
// A 200 with an empty body still means there is nothing to show. Without this the card
|
||||
// would sit there claiming "Already available" with no thumbnail and no explanation.
|
||||
this.existing[type].previewUnavailable = true;
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.existing[type].previewUnavailable = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(documentType: CkycDocumentType): boolean {
|
||||
const entry = this.existing[documentType];
|
||||
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(documentType: CkycDocumentType): void {
|
||||
const url = this.existing[documentType]?.previewUrl;
|
||||
if (url) {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
}
|
||||
|
||||
toggleReplace(documentType: string): void {
|
||||
const entry = (this.existing as any)[documentType];
|
||||
if (entry) entry.expanded = !entry.expanded;
|
||||
@@ -309,11 +377,38 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
this.selectedAccountType = this.selectedAccount?.accountType || '';
|
||||
}
|
||||
|
||||
onFileSelect(event: any, documentType: string): void {
|
||||
const file = event.target?.files?.[0];
|
||||
if (!file) return;
|
||||
/** Extensions a given card can both store and render. */
|
||||
private allowedExtensions(documentType: string): string[] {
|
||||
return IMAGE_ONLY_TYPES.includes(documentType as CkycDocumentType) ? 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, documentType: string): string | null {
|
||||
const allowed = this.allowedExtensions(documentType);
|
||||
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;
|
||||
}
|
||||
|
||||
private setFile(documentType: string, file: File | null): void {
|
||||
switch (documentType) {
|
||||
case 'ID_PROOF':
|
||||
this.idProofFile = file;
|
||||
break;
|
||||
case 'ADDRESS_PROOF':
|
||||
this.addressProofFile = file;
|
||||
break;
|
||||
@@ -327,11 +422,28 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
this.clientSignatureFile = file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onFileSelect(event: any, documentType: string): void {
|
||||
const file = event.target?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const error = this.validationError(file, documentType);
|
||||
if (error) {
|
||||
this.setFile(documentType, null);
|
||||
this.uploadErrors[documentType] = error;
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.setFile(documentType, file);
|
||||
this.uploadErrors[documentType] = null;
|
||||
}
|
||||
|
||||
getFile(documentType: string): File | null {
|
||||
switch (documentType) {
|
||||
case 'ID_PROOF':
|
||||
return this.idProofFile;
|
||||
case 'ADDRESS_PROOF':
|
||||
return this.addressProofFile;
|
||||
case 'PHOTO_PROOF':
|
||||
@@ -345,38 +457,64 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
uploadDocument(documentType: string): void {
|
||||
uploadDocument(documentType: CkycDocumentType): void {
|
||||
const file = this.getFile(documentType);
|
||||
if (!file) return;
|
||||
|
||||
this.uploading[documentType] = true;
|
||||
this.uploadErrors[documentType] = null;
|
||||
|
||||
// DocumentApiResource consumes multipart/form-data with body parts `file`, `name` and
|
||||
// `description` (file + description mandatory). There is no document-type field in that
|
||||
// contract, so the artefact type is carried in `name` - which is also the key
|
||||
// loadExistingArtefacts() matches on when reading the document back.
|
||||
const meta = CKYC_DOCUMENT_META[documentType];
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('documentType', documentType);
|
||||
formData.append('name', meta.name);
|
||||
formData.append('description', meta.description);
|
||||
|
||||
this.clientsService
|
||||
.uploadCkycDocument(this.clientId, formData)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.uploading[documentType] = false;
|
||||
this.uploadResponses[documentType] = response;
|
||||
this.alertService.alert({
|
||||
type: 'success',
|
||||
message: response?.message || 'Document uploaded successfully.'
|
||||
});
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.uploading[documentType] = false;
|
||||
this.uploadErrors[documentType] =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'Failed to upload document.';
|
||||
}
|
||||
});
|
||||
// Replace in place when this artefact already has a usable document, so a re-upload updates the
|
||||
// existing row (same document id, same content-store location) instead of stacking up another
|
||||
// copy. Falls back to create for the first upload of an artefact.
|
||||
//
|
||||
// Deliberately create instead of replace when the stored file is missing: the backend's update
|
||||
// path writes the new content relative to the row's existing `location` and never calls
|
||||
// setLocation(), so replacing a row whose file is already gone writes the upload to a fresh
|
||||
// folder while the row keeps pointing at the missing one - the artefact would stay broken and
|
||||
// the user would get a success message. Creating a new row repairs the card instead.
|
||||
const entry = this.existing[documentType];
|
||||
const replacing = !!entry?.documentId && !entry?.previewUnavailable;
|
||||
const existingId = entry?.documentId;
|
||||
|
||||
const request$ = replacing
|
||||
? this.clientsService.replaceClientDocument(this.clientId, String(existingId), formData)
|
||||
: this.clientsService.uploadCkycDocument(this.clientId, formData);
|
||||
|
||||
request$.pipe(takeUntil(this.destroy$)).subscribe({
|
||||
next: (response: any) => {
|
||||
this.uploading[documentType] = false;
|
||||
this.uploadResponses[documentType] = response;
|
||||
this.setFile(documentType, null);
|
||||
this.alertService.alert({
|
||||
type: 'success',
|
||||
message:
|
||||
response?.message || (replacing ? 'Document replaced successfully.' : 'Document uploaded successfully.')
|
||||
});
|
||||
// Re-read from m_document so the card reflects what was actually stored, rather than
|
||||
// assuming the write succeeded exactly as sent.
|
||||
this.loadExistingArtefacts();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.uploading[documentType] = false;
|
||||
this.uploadErrors[documentType] =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'Failed to upload document.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
|
||||
Reference in New Issue
Block a user