The CKYC tab's document cards now open a dedicated dialog (with client-side
preview) instead of expanding inline, collecting document type, number, and
issue/expiry dates the same way client creation does. Fixes several bugs
found along the way:
- Uploading a document type that's valid for both identity and address
(Aadhaar, Passport, etc.) no longer 404s or 500s — the "no such endpoint"
and case-sensitive ClientIdentifierStatus.valueOf("Active") crashes are
both resolved.
- Edit Client's Identity-proof and Address-proof steps no longer show each
other's document when both are dual-purpose OVD types, and no longer show
stale/mismatched front-back scans after a document type change.
- Address-proof's own document preview never showed a document at all when
no m_client_kyc record existed yet.
Document number and issue/expiry dates are persisted on the identifier
(documentKey + a shared "Issue date: ..., Expiry date: ..." description
convention) and read back by both the CKYC tab and Edit Client so the same
details appear everywhere.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
700 lines
27 KiB
TypeScript
700 lines
27 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;
|
|
}
|
|
|
|
type ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF';
|
|
type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE';
|
|
|
|
@Component({
|
|
selector: 'mifosx-ckyc-tab',
|
|
templateUrl: './ckyc-tab.component.html',
|
|
styleUrls: ['./ckyc-tab.component.scss'],
|
|
standalone: true,
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
MatIcon,
|
|
MatProgressSpinner,
|
|
FaIconComponent
|
|
]
|
|
})
|
|
export class CkycTabComponent implements OnInit, OnDestroy {
|
|
private route = inject(ActivatedRoute);
|
|
private clientsService = inject(ClientsService);
|
|
private alertService = inject(AlertService);
|
|
private dialog = inject(MatDialog);
|
|
private dates = inject(Dates);
|
|
|
|
private destroy$ = new Subject<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'];
|
|
this.loadClientInfo();
|
|
this.loadAccounts();
|
|
this.loadCkycData();
|
|
this.loadExistingArtefacts();
|
|
});
|
|
}
|
|
|
|
private loadClientInfo(): void {
|
|
this.route.parent.data.subscribe((data: any) => {
|
|
const cv = data?.clientViewData;
|
|
if (cv) {
|
|
this.clientName = cv.displayName || '';
|
|
this.clientEmail = cv.emailAddress || '';
|
|
this.clientMobile = cv.mobileNo || '';
|
|
this.cifNumber = cv.accountNo || cv.externalId || '';
|
|
}
|
|
});
|
|
}
|
|
|
|
private loadAccounts(): void {
|
|
this.clientsService
|
|
.getClientAccountData(this.clientId)
|
|
.pipe(
|
|
takeUntil(this.destroy$),
|
|
catchError(() => of(null))
|
|
)
|
|
.subscribe((data: any) => {
|
|
if (data) {
|
|
const savings = (data.savingsAccounts || []).map((a: any) => ({
|
|
accountNo: a.accountNo,
|
|
accountType: 'Savings',
|
|
productName: a.productName || '',
|
|
id: a.id
|
|
}));
|
|
const loans = (data.loanAccounts || []).map((a: any) => ({
|
|
accountNo: a.accountNo,
|
|
accountType: 'Loan',
|
|
productName: a.productName || '',
|
|
id: a.id
|
|
}));
|
|
this.allAccounts = [
|
|
...savings,
|
|
...loans
|
|
];
|
|
}
|
|
});
|
|
}
|
|
|
|
private loadCkycData(): void {
|
|
this.loading = true;
|
|
// Read CKYC registry data from /clients/{id}/kyc — that's where the
|
|
// SFTP upload flow persists ckycNumber / ckycUploadedOn / ckycBatchId.
|
|
this.clientsService
|
|
.getClientKyc(this.clientId)
|
|
.pipe(
|
|
takeUntil(this.destroy$),
|
|
catchError(() => {
|
|
this.loading = false;
|
|
return of(null);
|
|
})
|
|
)
|
|
.subscribe((data: any) => {
|
|
this.loading = false;
|
|
this.ckycData = data;
|
|
this.ckycNumber = data?.ckycNumber || '';
|
|
this.ckycUploadedOn = this.formatJavaDate(data?.ckycUploadedOn);
|
|
this.ckycBatchId = data?.ckycBatchId || '';
|
|
this.panVerified = data?.panVerified === true || data?.panVerified === 'true' || data?.panVerified === 1;
|
|
this.aadhaarVerified =
|
|
data?.aadhaarVerified === true || data?.aadhaarVerified === 'true' || data?.aadhaarVerified === 1;
|
|
this.panHolderName = data?.panFullName || data?.panHolderName || '';
|
|
this.aadhaarHolderName = data?.aadhaarHolderName || '';
|
|
this.panNumber = data?.panNumber || '';
|
|
this.aadhaarMasked = data?.aadhaarNumberMasked || '';
|
|
});
|
|
}
|
|
|
|
private formatJavaDate(value: any): string {
|
|
if (!value) return '';
|
|
if (Array.isArray(value) && value.length >= 3) {
|
|
const [
|
|
y,
|
|
m,
|
|
d
|
|
] = value;
|
|
return `${String(d).padStart(2, '0')}-${String(m).padStart(2, '0')}-${y}`;
|
|
}
|
|
if (typeof value === 'string') return value;
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Fetch photo (profile image), signature (client document),
|
|
* ID proof and address proof (client identifiers) so already-uploaded
|
|
* artefacts are shown here instead of forcing re-upload.
|
|
*/
|
|
private loadExistingArtefacts(): void {
|
|
this.checkingExisting = true;
|
|
|
|
forkJoin({
|
|
photo: this.clientsService.getClientProfileImage(this.clientId).pipe(catchError(() => of(null))),
|
|
documents: this.clientsService.getClientDocuments(this.clientId).pipe(catchError(() => of([]))),
|
|
identifiers: this.clientsService.getClientIdentifiers(this.clientId).pipe(catchError(() => of([]))),
|
|
proofTypes: this.clientsService.getKycProofTypes().pipe(catchError(() => of({ idProofs: [], addressProofs: [] })))
|
|
})
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe(({ photo, documents, identifiers, proofTypes }) => {
|
|
this.checkingExisting = false;
|
|
|
|
// 1. Photo / image of client — from m_client.image_id
|
|
if (photo && typeof photo === 'string' && photo.length > 0) {
|
|
this.existing.CLIENT_IMAGE = {
|
|
present: true,
|
|
source: 'Client registration',
|
|
previewUrl: photo,
|
|
name: 'Profile photo'
|
|
};
|
|
}
|
|
|
|
// 2. Signature — client document with name='clientSignature'. Multiple rows can exist
|
|
// for clients who re-cropped/replaced their signature before uploads became single-slot,
|
|
// so pick the highest id (latest) rather than the first unordered match.
|
|
const signatureDoc = (documents as any[])
|
|
?.filter((d: any) => d?.name === 'clientSignature')
|
|
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
|
if (signatureDoc) {
|
|
this.existing.CLIENT_SIGNATURE = {
|
|
present: true,
|
|
source: 'Client registration',
|
|
name: signatureDoc.fileName || 'Client signature',
|
|
documentId: signatureDoc.id
|
|
};
|
|
// Fetch blob for preview
|
|
this.clientsService
|
|
.getClientSignatureImage(this.clientId, signatureDoc.id)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe({
|
|
next: (blob: any) => {
|
|
if (blob) {
|
|
this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
|
|
}
|
|
},
|
|
error: () => undefined
|
|
});
|
|
}
|
|
|
|
// 3 & 4. ID proof + Address proof — client identifiers classified via proof-types
|
|
this.idProofOptions = (proofTypes?.idProofs || []).map((p: any) => normalizeProofType(p));
|
|
this.addressProofOptions = (proofTypes?.addressProofs || []).map((p: any) => normalizeProofType(p));
|
|
const idProofIds = new Set<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;
|
|
|
|
const byNewest = (a: any, b: any) => b.id - a.id;
|
|
const label = (d: any) => (d?.name || d?.fileName || '').toString();
|
|
const back = [...list].filter((d: any) => /back/i.test(label(d))).sort(byNewest)[0];
|
|
const front =
|
|
[...list].filter((d: any) => /front/i.test(label(d))).sort(byNewest)[0] ||
|
|
[...list].filter((d: any) => d.id !== back?.id).sort(byNewest)[0];
|
|
|
|
if (front) this.downloadIdentifierDocPreview(identifierId, front.id, kind, 'front');
|
|
if (back && back.id !== front?.id) this.downloadIdentifierDocPreview(identifierId, back.id, kind, 'back');
|
|
});
|
|
}
|
|
|
|
private downloadIdentifierDocPreview(
|
|
identifierId: string,
|
|
documentId: string,
|
|
kind: ProofKind,
|
|
slot: 'front' | 'back'
|
|
): void {
|
|
this.clientsService
|
|
.downloadClientIdentificationDocument(identifierId, documentId)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe({
|
|
next: (blob: any) => {
|
|
if (!blob) return;
|
|
const url = URL.createObjectURL(blob);
|
|
if (slot === 'front') this.existing[kind].previewUrl = url;
|
|
else this.existing[kind].previewUrlBack = url;
|
|
},
|
|
error: () => undefined
|
|
});
|
|
}
|
|
|
|
onAccountSelect(accountNo: string): void {
|
|
this.selectedAccount = this.allAccounts.find((a) => a.accountNo === accountNo) || null;
|
|
this.selectedAccountType = this.selectedAccount?.accountType || '';
|
|
}
|
|
|
|
getFile(documentType: string): File | null {
|
|
switch (documentType) {
|
|
case 'CLIENT_IMAGE':
|
|
return this.clientImageFile;
|
|
case 'CLIENT_SIGNATURE':
|
|
return this.clientSignatureFile;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static readonly CARD_TITLES: Record<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;
|
|
if (isProofKind) {
|
|
const proofKind = kind as ProofKind;
|
|
this.selectedProofType[proofKind] = result.proofType || null;
|
|
this.docFiles[proofKind] = { front: result.front, back: result.back || null };
|
|
this.documentDetails[proofKind] = {
|
|
documentKey: result.documentKey || '',
|
|
issueDate: result.issueDate || null,
|
|
expiryDate: result.expiryDate || null
|
|
};
|
|
} else if (kind === 'CLIENT_IMAGE') {
|
|
this.clientImageFile = result.front;
|
|
} else {
|
|
this.clientSignatureFile = result.front;
|
|
}
|
|
this.uploadDocument(kind);
|
|
});
|
|
}
|
|
|
|
/** Reads back the "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" convention this tab (and
|
|
* client-india-address-step, for address proof) writes into the identifier's description. */
|
|
private parseDescriptionDates(description: string | undefined): { issueDate: Date | null; expiryDate: Date | null } {
|
|
const issueMatch = (description || '').match(/Issue date:\s*([^,|]+)/i);
|
|
const expiryMatch = (description || '').match(/Expiry date:\s*([^,|]+)/i);
|
|
return {
|
|
issueDate: issueMatch ? this.dates.parseDate(issueMatch[1].trim()) : null,
|
|
expiryDate: expiryMatch ? this.dates.parseDate(expiryMatch[1].trim()) : null
|
|
};
|
|
}
|
|
|
|
private buildDescription(issueDate: string | null, expiryDate: string | null): string {
|
|
const parts: string[] = [];
|
|
if (issueDate) parts.push(`Issue date: ${issueDate}`);
|
|
if (expiryDate) parts.push(`Expiry date: ${expiryDate}`);
|
|
return parts.join(', ');
|
|
}
|
|
|
|
/** Issue/expiry date for display on the card, formatted dd-MM-yyyy — parsed from the same
|
|
* description text the upload dialog writes. */
|
|
cardDate(kind: ProofKind, which: 'issue' | 'expiry'): string {
|
|
const parsed = this.parseDescriptionDates(this.existing[kind].description);
|
|
const value = which === 'issue' ? parsed.issueDate : parsed.expiryDate;
|
|
return value ? this.dates.formatDate(value, 'dd-MM-yyyy') : '';
|
|
}
|
|
|
|
/**
|
|
* Routes each card to whichever real endpoint that artefact is actually stored through — there
|
|
* is no single "/clients/{id}/ckyc/documents" resource on the backend, so posting every card to
|
|
* one made-up URL 404'd for all four. A photo goes to the image endpoint, a signature to the
|
|
* client's own documents, and an ID/address proof to the m_client_identifier it already belongs
|
|
* to (the same one the Identities tab and create-client stepper post to) — never a fresh,
|
|
* type-less upload, since which identifier a proof belongs to is exactly what makes it that
|
|
* proof rather than some other file on the client's record.
|
|
*/
|
|
uploadDocument(documentType: string): void {
|
|
if (documentType === 'PHOTO_PROOF' || documentType === 'ADDRESS_PROOF') {
|
|
this.uploadProofDocument(documentType);
|
|
return;
|
|
}
|
|
|
|
const file = this.getFile(documentType);
|
|
if (!file) return;
|
|
|
|
this.uploading[documentType] = true;
|
|
this.uploadErrors[documentType] = null;
|
|
const key = documentType as keyof typeof this.existing;
|
|
|
|
let request$: Observable<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;
|
|
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!);
|
|
}
|
|
switch (documentType) {
|
|
case 'CLIENT_IMAGE':
|
|
this.clientsService
|
|
.getClientProfileImage(this.clientId)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe((photo: any) => {
|
|
if (photo) this.existing.CLIENT_IMAGE.previewUrl = photo;
|
|
});
|
|
break;
|
|
case 'CLIENT_SIGNATURE':
|
|
this.clientsService
|
|
.getClientDocuments(this.clientId)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe((documents: any) => {
|
|
const doc = (documents as any[])
|
|
?.filter((d: any) => d?.name === 'clientSignature')
|
|
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
|
if (!doc) return;
|
|
this.existing.CLIENT_SIGNATURE.documentId = doc.id;
|
|
this.clientsService
|
|
.getClientSignatureImage(this.clientId, doc.id)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe((blob: any) => {
|
|
if (blob) this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
|
|
});
|
|
});
|
|
break;
|
|
case 'PHOTO_PROOF':
|
|
case 'ADDRESS_PROOF':
|
|
this.loadIdentifierDocPreview(documentType, this.existing[documentType].documentId!);
|
|
break;
|
|
}
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.destroy$.next();
|
|
this.destroy$.complete();
|
|
// Release object URLs
|
|
Object.values(this.existing).forEach((e) => {
|
|
if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
|
|
if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack);
|
|
});
|
|
}
|
|
}
|