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>
887 lines
35 KiB
TypeScript
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();
|
|
}
|
|
}
|