Client-stepper: pre-fill existing KYC/address proofs in edit, fix identifier saves
Edit-mode now shows the previously selected KYC document type + uploaded file thumbnail
(loads from /clients/{id}/identifiers + /client_identifiers/{id}/documents). Same for
address proof on the India address step. Closes the gap where editing a client showed
an empty file picker even when proof had been uploaded on create.
Identifier-save hardened across all three callsites (create-client, edit-client,
identities-tab.createIdentifierWithDocs) - the KYC chip picker proofId is a CODE
string ('AADHAAR'), but backend ClientIdentifierRequest.documentTypeId is Long, which
Spring rejects with HttpMessageNotReadableException -> 400. resolveDocumentTypeId
helper looks up the code against /kyc/proof-types?type=id and substitutes the numeric
CodeValue id before posting. Skips silently for constitution-specific non-individual
docs that have no CodeValue mapping.
KYC payload normalises panNumber / enterprisePan / gstinNumber / cinNumber via
toUpperCase + whitespace strip so user-typed lowercase or trailing spaces don't trip
the backend regex (validation.msg.CLIENTKYC.panNumber.does.not.match.regexp).
State name match in client-address save now normalises both sides (lowercase, collapse
underscores + spaces) so legacy MADHYA_PRADESH resolves to 'Madhya Pradesh' returned
by api.postalpincode.in. Backed by tenant migration 0297.
Identities-tab resolver flattened - was returning identifiers before the per-row
forkJoin(getClientIdentificationDocuments) completed, so the view rendered empty
documents arrays. Now uses switchMap + forkJoin so the resolver awaits completion.
Also bundles concurrent work-in-progress on letter-mgmt, organization, users,
bank-calendar, shell, nominee/notes/identifier dialogs, and theming.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
325228c514
commit
83ea57ccf0
@@ -90,6 +90,23 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
kycDocMergedFile: File | null = null;
|
||||
kycDocMergedPreview: string | null = null;
|
||||
|
||||
/**
|
||||
* Documents already on the server for the linked client_identifier row (edit mode).
|
||||
* Populated in loadExistingKycIdentifier() so the form can show the user what proof
|
||||
* they uploaded previously instead of presenting an empty file picker.
|
||||
*
|
||||
* Each entry: { id, name, fileName, type, size, previewUrl (base64 data URL for images) }
|
||||
*/
|
||||
existingKycIdentifierId: string | null = null;
|
||||
existingKycDocuments: {
|
||||
id: string;
|
||||
name: string;
|
||||
fileName: string;
|
||||
type: string;
|
||||
size: number;
|
||||
previewUrl: string | null;
|
||||
}[] = [];
|
||||
|
||||
/** Set true after a save attempt so we can highlight missing photo/signature boxes */
|
||||
photoSigTouched = false;
|
||||
|
||||
@@ -522,6 +539,36 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.kycDocMergedPreview = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the saved KYC ID-proof document in a new browser tab.
|
||||
*
|
||||
* Same flow as the address-step variant: images use the already-loaded
|
||||
* base64 data URL; PDFs (or anything without a cached preview) are fetched
|
||||
* fresh and rendered via a temporary blob URL.
|
||||
*/
|
||||
viewExistingKycDoc(): void {
|
||||
if (!this.existingKycIdentifierId || this.existingKycDocuments.length === 0) {
|
||||
return;
|
||||
}
|
||||
const doc = this.existingKycDocuments[0];
|
||||
if (doc.previewUrl) {
|
||||
window.open(doc.previewUrl, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId, doc.id).subscribe({
|
||||
next: (blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const w = window.open(url, '_blank', 'noopener');
|
||||
if (w) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
/* silent — UI still shows the saved filename */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshKycDocMerged(): Promise<void> {
|
||||
const proof = this.selectedKycDoc;
|
||||
if (!proof || proof.sides !== 2 || !this.kycDocFiles.front || !this.kycDocFiles.back) {
|
||||
@@ -662,6 +709,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
// Load existing photo & signature
|
||||
this.loadExistingPhoto();
|
||||
this.loadExistingSignature();
|
||||
// Show the proof file the user uploaded previously, by matching the KYC document
|
||||
// name against the client_identifier rows on the server.
|
||||
this.loadExistingKycIdentifier(data?.kycDocumentName);
|
||||
},
|
||||
error: () => {
|
||||
// No existing KYC — that's fine for new clients
|
||||
@@ -670,6 +720,86 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the client_identifier row that backs the KYC proof and any documents attached to
|
||||
* it. Address proof + KYC proof both end up as client_identifier rows (see
|
||||
* create-client.saveKycDocProof + saveAddressProof) — so we filter against `kycDocProofs`
|
||||
* before falling back, otherwise the KYC tab could mistakenly show the address proof's file.
|
||||
*
|
||||
* @param kycDocumentName the document type captured on the KYC record — used to pick
|
||||
* the right identifier when a client has more than one
|
||||
*/
|
||||
private loadExistingKycIdentifier(kycDocumentName: string | undefined): void {
|
||||
if (!this.clientId) return;
|
||||
this.clientsService.getClientIdentifiers(this.clientId).subscribe({
|
||||
next: (identifiers: any[]) => {
|
||||
if (!identifiers || identifiers.length === 0) return;
|
||||
const isKycType = (i: any) => {
|
||||
const n = i?.documentType?.name?.toLowerCase();
|
||||
return !!n && this.kycDocProofs.some((p) => p.name?.toLowerCase() === n);
|
||||
};
|
||||
// Pick by document-type name match first; otherwise the first identifier whose
|
||||
// type lives in the KYC proof list (avoid bleeding the address proof in here).
|
||||
const match = kycDocumentName
|
||||
? identifiers.find((i: any) => i?.documentType?.name?.toLowerCase() === kycDocumentName.toLowerCase())
|
||||
: null;
|
||||
const identifier = match || identifiers.find(isKycType);
|
||||
if (!identifier?.id) return;
|
||||
|
||||
this.existingKycIdentifierId = identifier.id.toString();
|
||||
// Backfill document type + number on the form in case the KYC record didn't have
|
||||
// them (older clients were created via the identifier flow only).
|
||||
if (!this.kycForm.get('kycDocumentName')?.value && identifier?.documentType?.name) {
|
||||
this.kycForm.patchValue({
|
||||
kycDocumentName: identifier.documentType.name,
|
||||
kycDocumentNumber: identifier.documentKey || ''
|
||||
});
|
||||
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === identifier.documentType.name) || null;
|
||||
}
|
||||
|
||||
this.clientsService.getClientIdentificationDocuments(this.existingKycIdentifierId).subscribe({
|
||||
next: (docs: any[]) => {
|
||||
if (!docs || docs.length === 0) return;
|
||||
this.existingKycDocuments = docs.map((d: any) => ({
|
||||
id: d.id?.toString() || '',
|
||||
name: d.name || '',
|
||||
fileName: d.fileName || '',
|
||||
type: d.type || '',
|
||||
size: d.size || 0,
|
||||
previewUrl: null as string | null
|
||||
}));
|
||||
// Build a preview for the first image-typed document so the UI can show
|
||||
// exactly what the user uploaded last time.
|
||||
const firstImage = this.existingKycDocuments.find((d) => (d.type || '').toLowerCase().startsWith('image/'));
|
||||
if (firstImage) {
|
||||
this.clientsService
|
||||
.downloadClientIdentificationDocument(this.existingKycIdentifierId!, firstImage.id)
|
||||
.subscribe({
|
||||
next: async (blob: any) => {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
firstImage.previewUrl = 'data:' + blob.type + ';base64,' + btoa(binary);
|
||||
// Mirror onto the merged-preview slot so the existing chip-picker
|
||||
// visualisation lights up without template changes.
|
||||
if (!this.kycDocMergedPreview) {
|
||||
this.kycDocMergedPreview = firstImage.previewUrl;
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch existing client profile photo */
|
||||
private loadExistingPhoto(): void {
|
||||
if (!this.clientId) return;
|
||||
@@ -801,9 +931,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.panVerifyError = 'Client must be created first. Complete General tab and click Next.';
|
||||
return;
|
||||
}
|
||||
const pan = this.kycForm.value.panNumber;
|
||||
const pan = (this.kycForm.value.panNumber || '').toUpperCase().trim();
|
||||
if (!pan) {
|
||||
this.panVerifyError = 'Enter PAN number first.';
|
||||
this.kycForm.get('panNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
// Block API call when the PAN format is invalid — Fineract would accept
|
||||
// the raw value otherwise and persist a junk record.
|
||||
if (!/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/.test(pan)) {
|
||||
this.panVerifyError = 'Invalid PAN format. Expected pattern: ABCDE1234F (5 letters, 4 digits, 1 letter).';
|
||||
this.kycForm.get('panNumber')?.setErrors({ pattern: true });
|
||||
this.kycForm.get('panNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -893,8 +1032,20 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
*/
|
||||
verifyEnterprisePan() {
|
||||
if (!this.clientId) return;
|
||||
const pan = this.kycForm.value.enterprisePan;
|
||||
if (!pan || this.kycForm.get('enterprisePan')?.invalid) return;
|
||||
const pan = (this.kycForm.value.enterprisePan || '').toUpperCase().trim();
|
||||
if (!pan) {
|
||||
this.showPanErrorAlert('Enter Enterprise PAN number first.');
|
||||
this.kycForm.get('enterprisePan')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/.test(pan)) {
|
||||
this.showPanErrorAlert(
|
||||
'Invalid Enterprise PAN format. Expected pattern: ABCDE1234F (5 letters, 4 digits, 1 letter).'
|
||||
);
|
||||
this.kycForm.get('enterprisePan')?.setErrors({ pattern: true });
|
||||
this.kycForm.get('enterprisePan')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.entPanVerifying = true;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
@@ -955,8 +1106,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
/** Verify GSTIN via API */
|
||||
verifyGstin() {
|
||||
if (!this.clientId) return;
|
||||
const gstin = this.kycForm.value.gstinNumber;
|
||||
if (!gstin || this.kycForm.get('gstinNumber')?.invalid) return;
|
||||
const gstin = (this.kycForm.value.gstinNumber || '').toUpperCase().trim();
|
||||
if (!gstin) {
|
||||
this.showPanErrorAlert('Enter GSTIN number first.');
|
||||
this.kycForm.get('gstinNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
if (!/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/.test(gstin)) {
|
||||
this.showPanErrorAlert('Invalid GSTIN format. Expected pattern: 22AAAAA0000A1Z5 (15 characters).');
|
||||
this.kycForm.get('gstinNumber')?.setErrors({ pattern: true });
|
||||
this.kycForm.get('gstinNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.gstinVerifying = true;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
@@ -1019,8 +1180,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
/** Verify CIN (dummy — no real API) */
|
||||
verifyCin() {
|
||||
const cin = this.kycForm.value.cinNumber;
|
||||
if (!cin) return;
|
||||
const cin = (this.kycForm.value.cinNumber || '').toUpperCase().trim();
|
||||
if (!cin) {
|
||||
this.showPanErrorAlert('Enter CIN number first.');
|
||||
this.kycForm.get('cinNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Z]{1}[0-9]{5}[A-Z]{2}[0-9]{4}[A-Z]{3}[0-9]{6}$/.test(cin)) {
|
||||
this.showPanErrorAlert('Invalid CIN format. Expected pattern: L17110MH1973PLC019786 (21 characters).');
|
||||
this.kycForm.get('cinNumber')?.setErrors({ pattern: true });
|
||||
this.kycForm.get('cinNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.cinVerifying = true;
|
||||
this.cinProgressValue = 0;
|
||||
@@ -1068,9 +1239,16 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.aadhaarVerifyError = 'Client must be created first. Complete General tab and click Next.';
|
||||
return;
|
||||
}
|
||||
const aadhaar = this.kycForm.value.aadhaarNumber;
|
||||
if (!aadhaar || aadhaar.length !== 12) {
|
||||
this.aadhaarVerifyError = 'Enter valid 12-digit Aadhaar first.';
|
||||
const aadhaar = (this.kycForm.value.aadhaarNumber || '').trim();
|
||||
if (!aadhaar) {
|
||||
this.aadhaarVerifyError = 'Enter Aadhaar number first.';
|
||||
this.kycForm.get('aadhaarNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
if (!/^\d{12}$/.test(aadhaar)) {
|
||||
this.aadhaarVerifyError = 'Invalid Aadhaar format. Aadhaar must be exactly 12 digits.';
|
||||
this.kycForm.get('aadhaarNumber')?.setErrors({ pattern: true });
|
||||
this.kycForm.get('aadhaarNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1287,6 +1465,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const v = formVal;
|
||||
const formatDate = (d: any) => (d instanceof Date ? this.dateUtils.formatDate(d, dateFormat) : d || null);
|
||||
// Backend regex on these identifiers is upper-case only; tolerate user-typed lowercase
|
||||
// / whitespace here so the save doesn't 400 on
|
||||
// "validation.msg.CLIENTKYC.panNumber.does.not.match.regexp"
|
||||
// (or the equivalent for gstin / cin / enterprisePan).
|
||||
const norm = (s: any): string | null => {
|
||||
const out = (s || '').toString().toUpperCase().replace(/\s+/g, '');
|
||||
return out.length ? out : null;
|
||||
};
|
||||
const panNorm = norm(v.panNumber);
|
||||
const enterprisePanNorm = norm(v.enterprisePan);
|
||||
const gstinNorm = norm(v.gstinNumber);
|
||||
const cinNorm = norm(v.cinNumber);
|
||||
|
||||
return {
|
||||
locale: 'en',
|
||||
@@ -1294,12 +1484,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
kycType: 1,
|
||||
riskCategory: v.riskCategory || 1,
|
||||
// For non-individual: Company PAN is used as panNumber for verification
|
||||
panNumber: this.isNonIndividual ? v.enterprisePan || null : v.hasPan ? v.panNumber || null : null,
|
||||
panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null,
|
||||
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
|
||||
panAadhaarSeedingStatus: this.panAadhaarSeedingStatus || null,
|
||||
gstinNumber: v.gstinNumber || null,
|
||||
enterprisePan: v.enterprisePan || null,
|
||||
cinNumber: v.cinNumber || null,
|
||||
gstinNumber: gstinNorm,
|
||||
enterprisePan: enterprisePanNorm,
|
||||
cinNumber: cinNorm,
|
||||
incorporationDate: formatDate(v.incorporationDate),
|
||||
// KYC Document
|
||||
kycDocumentName: v.kycDocumentName || null,
|
||||
|
||||
Reference in New Issue
Block a user