/** * 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, QueryList, ViewChild, ViewChildren, inject } from '@angular/core'; import { Router, ActivatedRoute } from '@angular/router'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Observable, of } from 'rxjs'; import { map } from 'rxjs/operators'; /** Custom Services */ import { ClientsService } from '../clients.service'; import { SettingsService } from 'app/settings/settings.service'; import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; import { familyAllowed, nomineeAllowed } from '../utils/client-rules'; /** Custom Components */ import { ClientGeneralStepComponent } from '../client-stepper/client-general-step/client-general-step.component'; import { ClientAddressStepComponent } from '../client-stepper/client-address-step/client-address-step.component'; import { ClientIndiaAddressStepComponent } from '../client-stepper/client-india-address-step/client-india-address-step.component'; import { ClientEntityKycStepComponent } from '../client-stepper/client-entity-kyc-step/client-entity-kyc-step.component'; import { ClientDatatableStepComponent } from '../client-stepper/client-datatable-step/client-datatable-step.component'; import { ClientKycStepComponent } from '../client-stepper/client-kyc-step/client-kyc-step.component'; import { ClientPreviewStepComponent } from '../client-stepper/client-preview-step/client-preview-step.component'; import { ClientFamilyMembersStepComponent } from '../client-stepper/client-family-members-step/client-family-members-step.component'; import { ContactOtpBlockComponent, ContactOtpState } from './contact-otp-block/contact-otp-block.component'; import { MatTabsModule } from '@angular/material/tabs'; import { MatIconModule } from '@angular/material/icon'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatDivider } from '@angular/material/divider'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatTooltip } from '@angular/material/tooltip'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../shared/client-edit-diff.util'; import { toProperNameCase } from '../utils/name-case.util'; /** * Edit Client Component — uses the same wizard/stepper layout as Create Client. */ @Component({ selector: 'mifosx-edit-client', templateUrl: './edit-client.component.html', styleUrls: ['./edit-client.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, MatTabsModule, MatIconModule, MatCheckboxModule, MatDivider, FaIconComponent, MatProgressSpinner, MatTooltip, ClientGeneralStepComponent, ClientAddressStepComponent, ClientIndiaAddressStepComponent, ClientEntityKycStepComponent, ClientDatatableStepComponent, ClientKycStepComponent, ClientPreviewStepComponent, ClientFamilyMembersStepComponent, ContactOtpBlockComponent ] }) export class EditClientComponent { private route = inject(ActivatedRoute); private router = inject(Router); private dialog = inject(MatDialog); private clientsService = inject(ClientsService); private settingsService = inject(SettingsService); private snackBar = inject(MatSnackBar); @ViewChild(ClientGeneralStepComponent) clientGeneralStep: ClientGeneralStepComponent; @ViewChild('clientAddress') clientAddressStep: ClientAddressStepComponent; @ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent; @ViewChild(ClientEntityKycStepComponent) clientEntityKycStep: ClientEntityKycStepComponent; @ViewChild('clientKyc') clientKycStep: ClientKycStepComponent; @ViewChild(ClientFamilyMembersStepComponent) clientFamilyMembersStep: ClientFamilyMembersStepComponent; @ViewChildren('dtclient') clientDatatables: QueryList; datatables: any = []; legalFormType = 1; selectedTabIndex = 0; savingClient = false; /** Track which tabs are in edit mode (false = read-only, true = editable) */ tabEditMode: Record = {}; /** Flag: DOB was changed — requires re-KYC (PAN re-verification) */ dobChanged = false; /** * Dual-OTP contact-change verification state (mobileNo / emailAddress). Mirrors the * name/DOB "detect change against snapshot, gate the save" pattern already established * in this component (see `detectSensitiveFieldChanges()` / `flagReKycRequired()`) — * extended here so a changed mobile/email must be OTP-verified on BOTH the old and new * destination before Save is allowed. Payload param names sent to the backend PUT are * `mobileOldOtpId` / `mobileNewOtpId` / `emailOldOtpId` / `emailNewOtpId` (added onto * `clientData` in `saveCurrentTab()`'s General-tab branch) — kept per-field rather than * one generic old/new pair so mobile AND email can both be changed (and independently * OTP-verified) in a single save. */ oldMobileOtpId: number | null = null; newMobileOtpId: number | null = null; oldMobileVerified = false; newMobileVerified = false; // FIR exception flow properties for inaccessible old mobile number oldMobileNotAccessible = false; firFileName = ''; firDocumentId: number | null = null; uploadingFir = false; oldEmailOtpId: number | null = null; newEmailOtpId: number | null = null; oldEmailVerified = false; newEmailVerified = false; /** Client data and template (merged object from resolver) */ clientDataAndTemplate: any; /** Client ID */ clientId: string; /** True while a General-tab edit is staged awaiting checker approval — blocks submitting * another one (the backend enforces this too; this just avoids a round-trip error toast). */ hasPendingGeneralEdit = false; constructor() { this.route.data.subscribe((data: { clientDataAndTemplate: any }) => { this.clientDataAndTemplate = data.clientDataAndTemplate; this.clientId = this.clientDataAndTemplate.id; if (this.clientDataAndTemplate.legalForm) { this.legalFormType = this.clientDataAndTemplate.legalForm.id; } this.loadPendingGeneralEdit(); }); } private loadPendingGeneralEdit(): void { if (!this.clientId) return; this.clientsService.getPendingClientEdit(this.clientId).subscribe({ next: (edit: any) => (this.hasPendingGeneralEdit = !!edit && edit.status === 'PENDING'), error: () => (this.hasPendingGeneralEdit = false) }); } /** Check if a tab is in read-only mode */ isTabReadOnly(tabIndex: number): boolean { return !this.tabEditMode[tabIndex]; } /** Enable editing for a specific tab */ enableTabEdit(tabIndex: number): void { this.tabEditMode[tabIndex] = true; } /** Cancel editing — revert tab back to read-only */ cancelTabEdit(tabIndex: number): void { this.tabEditMode[tabIndex] = false; } get clientGeneralForm() { return this.clientGeneralStep?.createClientForm; } /** * Constitution display name pulled from the live general step (preferred — * stays in sync if the user changes constitution mid-edit) with a fallback * to the resolver-provided snapshot. Drives the proprietorship-only nominee * rule below. */ get constitutionLabel(): string { return ( this.clientGeneralStep?.selectedConstitution?.name || this.clientDataAndTemplate?.clientNonPersonDetails?.constitution?.name || '' ); } /** Whether the Family/Nominee tab should appear at all. */ get showFamilyTab(): boolean { return nomineeAllowed(this.legalFormType, this.constitutionLabel); } /** True when the tab should render in nominee-only mode (family rows hidden). */ get familyTabNomineeOnly(): boolean { return !familyAllowed(this.legalFormType, this.constitutionLabel) && this.showFamilyTab; } /** Total tab count varies by legal form + constitution. * Individual: General + KYC + Address + Family + Preview = 5 * Entity (proprietorship): General + KYC + S&S + Address + Nominees + Preview = 6 * Entity (non-proprietor): General + KYC + S&S + Address + Preview = 5 */ get totalTabs(): number { let count = 4; // General + KYC + Address + Preview if (this.legalFormType === 2) count++; // S&S only for entities if (this.showFamilyTab) count++; return count; } /** Get constitution type from general step for entity KYC */ getConstitutionType(): string { return this.clientGeneralStep?.createClientForm?.get('clientNonPersonDetails.constitutionId')?.value || ''; } get client() { const general = this.clientGeneralStep?.clientGeneralDetails || {}; return { ...general }; } /** * Live view of `detectSensitiveFieldChanges()` against the loaded snapshot — drives the * "Verify Contact Change" OTP panel in the General tab template. Re-evaluated on every * change-detection pass (cheap: a handful of form-control reads), so the panel appears/ * disappears as the user types, same as the existing re-KYC name/DOB detection. */ get contactChanges() { return this.detectSensitiveFieldChanges(this.clientDataAndTemplate); } /** Whether every OTP block required by the fields that actually changed has been verified * (or the FIR exception has been uploaded in place of old-mobile OTP verification). */ get contactVerificationComplete(): boolean { const changes = this.contactChanges; if (changes.mobileChanged) { const isOldSatisfied = this.oldMobileNotAccessible ? this.firDocumentId != null : this.oldMobileVerified; if (!isOldSatisfied || !this.newMobileVerified) { return false; } } if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) { return false; } return true; } /** Toggle the "old mobile not accessible" exception — clears any in-progress old-mobile OTP. */ onOldMobileNotAccessibleToggle(checked?: boolean): void { if (checked !== undefined) { this.oldMobileNotAccessible = checked; } if (this.oldMobileNotAccessible) { this.oldMobileOtpId = null; this.oldMobileVerified = false; } } /** Upload the FIR / police-complaint copy that substitutes for old-mobile OTP verification. */ onFirFileSelected(event: any): void { const file: File = event.target?.files?.[0]; if (!file) return; this.firFileName = file.name; this.uploadingFir = true; const formData = new FormData(); formData.append('file', file); formData.append('name', 'FIR - Lost Mobile Number'); formData.append('description', 'FIR police complaint copy for lost mobile number verification exception'); this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({ next: (res: any) => { this.uploadingFir = false; this.firDocumentId = res?.resourceId || res?.id || null; this.snackBar.open('FIR document uploaded successfully', 'Close', { duration: 3000 }); }, error: () => { this.uploadingFir = false; this.firDocumentId = null; this.snackBar.open('Failed to upload FIR document', 'Close', { duration: 4000 }); } }); } onOldMobileOtpState(state: ContactOtpState): void { this.oldMobileOtpId = state.otpId; this.oldMobileVerified = state.verified; } onNewMobileOtpState(state: ContactOtpState): void { this.newMobileOtpId = state.otpId; this.newMobileVerified = state.verified; } onOldEmailOtpState(state: ContactOtpState): void { this.oldEmailOtpId = state.otpId; this.oldEmailVerified = state.verified; } onNewEmailOtpState(state: ContactOtpState): void { this.newEmailOtpId = state.otpId; this.newEmailVerified = state.verified; } /** Reset all dual-OTP state — called once a General-tab save succeeds (the snapshot is * updated to match, so the "Verify Contact Change" panel naturally disappears too). */ private resetContactVerificationState(): void { this.oldMobileOtpId = null; this.newMobileOtpId = null; this.oldMobileVerified = false; this.newMobileVerified = false; this.oldMobileNotAccessible = false; this.firFileName = ''; this.firDocumentId = null; this.uploadingFir = false; this.oldEmailOtpId = null; this.newEmailOtpId = null; this.oldEmailVerified = false; this.newEmailVerified = false; } areFormvalids(): boolean { if (!this.clientGeneralStep || !this.clientGeneralForm) { return false; } let areValids = this.clientGeneralForm.valid; if (this.clientGeneralStep.sanctionsBlocked) { return false; } return areValids; } /** * Per-tab form-validity gate for the Save & Next button. Mirrors the create * flow — blocks navigation when the current tab has unfilled / invalid * required fields. Tab order: * 0 General * 1 KYC & Documents * 2 (non-individual) Scheme & Sector | (individual) Address * 3 (non-individual) Address | (individual) Family * 4 (non-individual) Family | Preview / Submit */ get currentStepValid(): boolean { if (this.savingClient) return false; const idx = this.selectedTabIndex; if (idx === 0) { if (!this.clientGeneralStep || !this.clientGeneralForm) return false; if (this.clientGeneralStep.sanctionsBlocked) return false; if (!this.clientGeneralForm.valid) return false; // Dual-OTP gate: a changed mobile/email must be verified on both old & new // destinations before Save is allowed — mirrors the re-KYC gate below it. return this.contactVerificationComplete; } if (idx === 1) { const kf = this.clientKycStep?.kycForm; return !kf || kf.valid; } if (this.legalFormType === 2) { if (idx === 2) return true; // Scheme & Sector — opt-in if (idx === 3) return this.isAddressStepValid(); if (idx === 4) return true; // Family — not applicable to non-individual entities } else { if (idx === 2) return this.isAddressStepValid(); if (idx === 3) return this.isFamilyStepValid(); // Mother required, plus Father or a Caretaker } return true; } /** Mother is required, and either Father or a Caretaker, for individual clients. */ private isFamilyStepValid(): boolean { const step = this.clientFamilyMembersStep; return !step || step.familyRelationshipRulesMet; } private isAddressStepValid(): boolean { const step = this.clientIndiaAddressStep; if (!step) return true; const perm = step.permanentForm; return !perm || perm.valid; } /** Human-readable explanation surfaced via matTooltip on the disabled button. */ get currentStepBlockerMessage(): string { if (this.savingClient) return 'Saving — please wait…'; if (this.currentStepValid) return ''; if (this.selectedTabIndex === 0 && this.clientGeneralStep?.sanctionsBlocked) { const disposition = this.clientGeneralStep.sanctionsDisposition; if (disposition === 'FALSE_POSITIVE') { return 'False-positive review submitted — awaiting Principal Officer approval before this can be saved.'; } if (disposition === 'CONFIRMED_MATCH') { return 'Confirmed UNSC sanctions match — the case has been escalated and saving is blocked.'; } return 'Sanctions match found — review the match and record an outcome before proceeding.'; } if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) { return 'Verify the OTP sent to both the old and new mobile number/email before saving.'; } if (this.selectedTabIndex === 1) { const labels = this.clientKycStep?.invalidFieldLabels || []; if (labels.length) { return `Please fill/correct: ${labels.join(', ')}.`; } } const familyTabIndex = this.legalFormType === 2 ? 4 : 3; if (this.selectedTabIndex === familyTabIndex && this.legalFormType !== 2 && this.clientFamilyMembersStep) { const step = this.clientFamilyMembersStep; if (!step.hasMother) return 'Mother is required as a family member.'; if (!step.hasFather && !step.hasCaretaker) return 'Either Father or a Caretaker is required as a family member.'; } return 'Please fill all mandatory fields on this tab to continue.'; } /** Whether the current tab has unsaved edits */ isCurrentTabEdited(): boolean { return this.tabEditMode[this.selectedTabIndex] === true; } /** * Next button handler — saves changes via API if the current tab was edited, then moves to next tab. */ onNext() { if (this.selectedTabIndex === 0) { if (!this.clientGeneralForm?.valid) { this.clientGeneralForm?.markAllAsTouched(); setTimeout(() => { const firstInvalid = document.querySelector('.mat-mdc-form-field.ng-invalid'); if (firstInvalid) { firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, 100); return; } } // If tab was edited, confirm and save before moving on if (this.isCurrentTabEdited()) { this.confirmAndSaveTab(); } else { this.selectedTabIndex = this.selectedTabIndex + 1; } } /** * Show confirmation dialog and save the current tab's data via API. * Shows a special warning if DOB was changed (re-KYC required). */ private confirmAndSaveTab() { let heading = 'Confirm Edit'; let dialogContext = 'Are you sure you want to save the changes'; let dialogType = 'basic'; // Check if name or DOB changed on the General tab — warn about re-KYC if (this.selectedTabIndex === 0) { const changes = this.detectSensitiveFieldChanges(this.clientDataAndTemplate); // Dual-OTP gate — defensive re-check. The "Save & Next" button is already disabled // via `currentStepValid` while this is incomplete, but don't rely on that alone. if (!this.contactVerificationComplete) { this.snackBar.open('Verify the OTP sent to both the old and new mobile number/email before saving.', 'Close', { duration: 5000 }); return; } if (changes.nameChanged || changes.dobChanged) { heading = 'Re-KYC Required'; dialogContext = 'Client name or date of birth has been changed. This will require PAN re-verification in the KYC section. A remark will be added automatically. Do you want to proceed'; dialogType = 'warn'; } } const dialogRef = this.dialog.open(ConfirmationDialogComponent, { data: { heading, dialogContext, type: dialogType } }); dialogRef.afterClosed().subscribe((response: any) => { if (response?.confirm) { this.saveCurrentTab(); } }); } /** * Save the current tab's data via API, then advance to the next tab. */ private saveCurrentTab() { const tabIndex = this.selectedTabIndex; // General Details tab (always index 0) if (tabIndex === 0) { const locale = this.settingsService.language.code; const dateFormat = this.settingsService.dateFormat; const clientData = { ...this.client, dateFormat, locale }; delete clientData.officeId; // Detect changes to sensitive fields before saving const originalData = this.clientDataAndTemplate; const changedFields = this.detectSensitiveFieldChanges(originalData); // Dual-OTP verified ids — sent only for the field(s) that actually changed, per-field // (not a single generic old/new pair) so the backend can tell mobile's OTPs apart from // email's when BOTH changed in the same save. See the class-level JSDoc above the OTP // state fields for the full naming rationale. if (changedFields.mobileChanged) { if (this.oldMobileNotAccessible && this.firDocumentId) { (clientData as any).oldMobileVerificationExceptionDocumentId = this.firDocumentId; } else { clientData.mobileOldOtpId = this.oldMobileOtpId; } clientData.mobileNewOtpId = this.newMobileOtpId; } if (changedFields.emailChanged) { clientData.emailOldOtpId = this.oldEmailOtpId; clientData.emailNewOtpId = this.newEmailOtpId; } // Point-in-time "before" snapshot for the checker's diff view — captured now, from the // client record loaded when this edit page opened, rather than relying on comparing against // the LIVE client later (which would show nothing changed once a checker approves the edit, // since by then current == proposed). clientData[ORIGINAL_VALUES_PAYLOAD_KEY] = buildOriginalValuesSnapshot(originalData); this.savingClient = true; // Maker-checker: stage the edit instead of applying it immediately. The customer-facing // side effects that a direct update would have had immediately — SMS/Email contact-change // notification, re-KYC flagging, PAN re-verification reset — are all deferred until a // different user actually approves the edit (approval replays this exact payload through // the same updateClient() the direct PUT used to call, so the backend-driven notification // still fires correctly, just at approval time instead of now). this.clientsService.submitClientEdit(this.clientId, clientData).subscribe({ next: () => { this.savingClient = false; this.tabEditMode[tabIndex] = false; this.hasPendingGeneralEdit = true; // Senior Citizen / PWD supporting documents — separate upload endpoints, not part of // the staged JSON edit, so these still apply immediately as before. if (this.clientGeneralStep?.seniorCitizenProofFile) { const seniorCitizenProofForm = new FormData(); seniorCitizenProofForm.append('file', this.clientGeneralStep.seniorCitizenProofFile); seniorCitizenProofForm.append('name', 'seniorCitizenProof'); seniorCitizenProofForm.append('description', 'Senior Citizen Proof (Age/ID Proof)'); this.clientsService .uploadClientDocument(this.clientId, seniorCitizenProofForm) .subscribe({ error: () => {} }); } if (this.clientGeneralStep?.pwdCertificateFile) { const pwdCertificateForm = new FormData(); pwdCertificateForm.append('file', this.clientGeneralStep.pwdCertificateFile); pwdCertificateForm.append('name', 'pwdCertificate'); pwdCertificateForm.append('description', 'Disability Certificate'); this.clientsService.uploadClientDocument(this.clientId, pwdCertificateForm).subscribe({ error: () => {} }); } this.snackBar.open( 'Edit submitted for approval — a different user must review it before it takes effect.', 'Close', { duration: 5000 } ); this.resetContactVerificationState(); this.selectedTabIndex = this.selectedTabIndex + 1; }, error: () => { this.savingClient = false; } }); return; } // KYC tab (always index 1) if (tabIndex === 1) { // Photograph + Signature are mandatory before leaving the KYC tab if (this.clientKycStep && !this.clientKycStep.photoSigComplete) { this.clientKycStep.markPhotoSigTouched(); return; } this.savingClient = true; // Upload photo if selected if (this.clientKycStep?.photographFile) { this.clientsService .uploadClientProfileImage(this.clientId, this.clientKycStep.photographFile) .subscribe({ error: () => {} }); } // Upload signature if selected if (this.clientKycStep?.signatureFile) { this.clientsService .uploadClientSignatureImage(this.clientId, this.clientKycStep.signatureFile) .subscribe({ error: () => {} }); } // Save KYC data — use correct API based on KYC state if (this.clientKycStep?.hasKycData) { const kycData = this.clientKycStep.kycData; const kycId = this.clientKycStep.kycId; const isVerified = this.clientKycStep.isKycVerified; // Remarks are not demanded here. The server requires them only when a save actually // overwrites data the verified record already held, and it names the reason when it // refuses. Blocking every verified record up front aborted the save silently — the photo // upload above had already gone through, so the image changed, the button came back to // life, and nothing said why nothing had been saved. let saveObs; if (kycId && isVerified) { // VERIFIED → re-edit command const { kycType, ...updatePayload } = kycData; saveObs = this.clientsService.reEditClientKyc(this.clientId, kycId, updatePayload); } else if (kycId) { // Existing but not verified → normal PUT const { kycType, ...updatePayload } = kycData; saveObs = this.clientsService.updateClientKyc(this.clientId, kycId, updatePayload); } else { // New → POST saveObs = this.clientsService.saveClientKyc(this.clientId, kycData); } saveObs.subscribe({ next: (res: any) => { this.savingClient = false; this.tabEditMode[tabIndex] = false; // If re-edit, update KYC component state if (isVerified && this.clientKycStep) { this.clientKycStep.kycStatus = 200; this.clientKycStep.panVerifyStatus = 'NONE'; this.clientKycStep.panVerifyName = ''; this.clientKycStep.panVerifiedOn = ''; } if (!kycId && res?.resourceId) { this.clientKycStep.kycId = res.resourceId.toString(); } // Persist KYC document picker selection + uploaded files as a client identifier this.saveKycDocProof(this.clientId); this.selectedTabIndex = this.selectedTabIndex + 1; }, // A rejected save used to just re-enable the button, which reads as "nothing happened". // The server's own message says what is missing — remarks on a re-edit, most often — so // it is shown rather than swallowed, and the KYC tab stays put for the officer to fix. error: (err: any) => { this.savingClient = false; const message = err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'KYC could not be saved. Please review the KYC details and try again.'; this.snackBar.open(message, 'Close', { duration: 6000 }); if (isVerified) { this.clientKycStep?.kycForm.get('remarks')?.markAsTouched(); } } }); } else { this.savingClient = false; this.tabEditMode[tabIndex] = false; this.selectedTabIndex = this.selectedTabIndex + 1; } return; } // Address tab — no OTP gate (an address has no "old/new channel" to verify an OTP // against); notification-only, per the plan. Save proceeds normally. const addressTabIdx = this.legalFormType === 2 ? 3 : 2; if (tabIndex === addressTabIdx) { this.savingClient = true; if (this.clientIndiaAddressStep?.hasAddressData) { this.saveClientAddresses(this.clientId); this.snackBar.open('Customer notified via SMS/Email of the address change.', 'Close', { duration: 4000 }); } this.savingClient = false; this.tabEditMode[tabIndex] = false; this.selectedTabIndex = this.selectedTabIndex + 1; return; } // Family tab — only present for individuals and proprietorship entities. // Skip when hidden so Preview at the same index isn't mistaken for Family. const familyTabIdx = this.legalFormType === 2 ? 4 : 3; if (this.showFamilyTab && tabIndex === familyTabIdx) { this.savingClient = true; if (this.clientFamilyMembersStep?.hasFamilyMembers) { this.saveClientFamilyMembers(this.clientId); } this.savingClient = false; this.tabEditMode[tabIndex] = false; this.selectedTabIndex = this.selectedTabIndex + 1; return; } // Default: just advance this.tabEditMode[tabIndex] = false; this.selectedTabIndex = this.selectedTabIndex + 1; } /** * Compare current form values with original data to detect changes in name, DOB, mobile, email. */ /** "YYYY-MM-DD" built from a Date's LOCAL components — see the timezone-safety note where * this is called from `detectSensitiveFieldChanges()`. */ private toLocalDateString(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } private detectSensitiveFieldChanges(originalData: any) { const form = this.clientGeneralForm; if (!form || !originalData) { return { nameChanged: false, mobileChanged: false, emailChanged: false, dobChanged: false, oldName: '', newName: '', oldMobile: '', newMobile: '', oldEmail: '', newEmail: '', oldDob: '', newDob: '' }; } const mobileControl = form.get('mobileNo'); const emailControl = form.get('emailAddress'); // Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as // a "change" — the masked preview shown in the Verify Contact Change panel only reveals the // first couple of characters, so a whitespace-only difference is otherwise invisible to the // user and just looks like the panel appeared for no reason. These trimmed-but-original-case // values are what get returned/sent as the actual OTP destination and saved value — only the // *comparison* (below) is further normalized for mobile/email, so we never silently rewrite the // customer's saved value or the OTP destination. const newMobile = (mobileControl?.value || '').toString().trim(); const newEmail = (emailControl?.value || '').toString().trim(); const newDobRaw = form.get('dateOfBirth')?.value; // Local calendar date, NOT `.toISOString()` — that converts to UTC first, which silently // shifts the date by a day in any positive-UTC-offset timezone (e.g. India, UTC+5:30: // midnight local on the 15th becomes 18:30 UTC on the 14th). That made this comparison // report a DOB "change" — falsely triggering the Re-KYC dialog — even when the user never // touched the date field, purely because the Date object round-tripped through the picker. const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || ''; const oldMobile = (originalData.mobileNo || '').toString().trim(); const oldEmail = (originalData.emailAddress || '').toString().trim(); const oldDobRaw = originalData.dateOfBirth; const oldDob = Array.isArray(oldDobRaw) ? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}` : oldDobRaw instanceof Date ? this.toLocalDateString(oldDobRaw) : oldDobRaw || ''; // Both sides run through toProperNameCase so a casing-only difference — the stored // 'LINCPAY LTD' against the normalized 'Lincpay LTD' the form now submits — is not // mistaken for a real rename and does not force a PAN re-verification. const buildName = (parts: { firstname?: string; middlename?: string; lastname?: string; fullname?: string }) => toProperNameCase( parts.fullname || [ parts.firstname, parts.middlename, parts.lastname ] .filter((p) => p && p.trim()) .join(' ') ); const newName = buildName({ firstname: form?.get('firstname')?.value || '', middlename: form?.get('middlename')?.value || '', lastname: form?.get('lastname')?.value || '', fullname: form?.get('fullname')?.value || '' }); const oldName = buildName({ firstname: originalData.firstname || '', middlename: originalData.middlename || '', lastname: originalData.lastname || '', fullname: originalData.fullname || '' }); // Normalize phone numbers by stripping non-digit characters (+91 9009009009 -> 9009009009) const normPhone = (val: string) => val.replace(/\D/g, ''); const newMobileNorm = normPhone(newMobile); const oldMobileNorm = normPhone(oldMobile); // Mobile/Email are considered changed ONLY when the control has been edited (dirty) AND // normalized values differ — otherwise a re-render/reformat of the control by the framework // (never actually touched by the user) would falsely surface the dual-OTP verification panel. const mobileChanged = !!oldMobileNorm && !!newMobileNorm && !!mobileControl?.dirty && newMobileNorm !== oldMobileNorm; // Case-insensitive: emails are not case-sensitive in practice, and comparing raw case // would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed. const emailChanged = !!oldEmail && !!newEmail && !!emailControl?.dirty && newEmail.toLowerCase() !== oldEmail.toLowerCase(); return { nameChanged: newName !== oldName, mobileChanged, emailChanged, dobChanged: newDob !== oldDob, oldName, newName, oldMobile, newMobile, oldEmail, newEmail, oldDob, newDob }; } /** * Save audit notes for mobile/email changes via the client notes API. */ private logFieldChangeNotes(changes: any) { const timestamp = new Date().toISOString(); if (changes.mobileChanged) { const note = `[AUDIT] Mobile number changed from "${changes.oldMobile}" to "${changes.newMobile}" on ${timestamp}`; this.clientsService.createClientNote(this.clientId, { note }).subscribe({ error: () => {} }); } if (changes.emailChanged) { const note = `[AUDIT] Email address changed from "${changes.oldEmail}" to "${changes.newEmail}" on ${timestamp}`; this.clientsService.createClientNote(this.clientId, { note }).subscribe({ error: () => {} }); } } /** * When name or DOB changes, reset PAN verification in KYC step and pre-fill remark for re-KYC. */ private flagReKycRequired(changes: { nameChanged: boolean; dobChanged: boolean; oldName: string; newName: string; oldDob: string; newDob: string; }) { const reasons: string[] = []; if (changes.nameChanged) { reasons.push(`Client name changed from "${changes.oldName}" to "${changes.newName}"`); } if (changes.dobChanged) { reasons.push(`Date of birth changed from "${changes.oldDob}" to "${changes.newDob}"`); } const reasonText = reasons.join('; '); // Reset PAN verification status so user must re-verify if (this.clientKycStep) { this.clientKycStep.panVerifyStatus = 'NONE'; this.clientKycStep.panVerifyName = ''; this.clientKycStep.panVerifiedOn = ''; // Pre-fill remarks with re-KYC note const remarkText = `[Re-KYC Required] ${reasonText}. PAN re-verification is mandatory.`; this.clientKycStep.kycForm.get('remarks')?.setValue(remarkText); } // Also log the change as an audit note const timestamp = new Date().toISOString(); const note = `[AUDIT] ${reasonText} on ${timestamp}. Re-KYC (PAN re-verification) required.`; this.clientsService.createClientNote(this.clientId, { note }).subscribe({ error: () => {} }); } legalFormChange(eventData: { legalForm: number }) { this.legalFormType = eventData.legalForm; } /** * Submits the edit client form. */ submit() { // Photograph + Signature are mandatory before submitting if (this.clientKycStep && !this.clientKycStep.photoSigComplete) { this.clientKycStep.markPhotoSigTouched(); this.selectedTabIndex = 1; return; } const locale = this.settingsService.language.code; const dateFormat = this.settingsService.dateFormat; const clientData = { ...this.client, dateFormat, locale }; // Remove fields not accepted by update API delete clientData.officeId; this.savingClient = true; this.clientsService.updateClient(this.clientId, clientData).subscribe({ next: () => { this.savingClient = false; const navigateBack = () => this.router.navigate(['../'], { relativeTo: this.route }); // Upload photo if selected if (this.clientKycStep?.photographFile) { this.clientsService .uploadClientProfileImage(this.clientId, this.clientKycStep.photographFile) .subscribe({ error: () => {} }); } // Upload signature if selected if (this.clientKycStep?.signatureFile) { this.clientsService .uploadClientSignatureImage(this.clientId, this.clientKycStep.signatureFile) .subscribe({ error: () => {} }); } // Save addresses from India address step if (this.clientIndiaAddressStep?.hasAddressData) { this.saveClientAddresses(this.clientId); } // Save new family members if (this.clientFamilyMembersStep?.hasFamilyMembers) { this.saveClientFamilyMembers(this.clientId); } // Save/update KYC data if available if (this.clientKycStep?.hasKycData) { const kycData = this.clientKycStep.kycData; const kycId = this.clientKycStep.kycId; const isVerified = this.clientKycStep.isKycVerified; let kycObs; if (kycId && isVerified) { const { kycType, ...updatePayload } = kycData; kycObs = this.clientsService.reEditClientKyc(this.clientId, kycId, updatePayload); } else if (kycId) { const { kycType, ...updatePayload } = kycData; kycObs = this.clientsService.updateClientKyc(this.clientId, kycId, updatePayload); } else { kycObs = this.clientsService.saveClientKyc(this.clientId, kycData); } kycObs.subscribe({ next: () => { this.saveKycDocProof(this.clientId); navigateBack(); }, error: () => navigateBack() }); } else { this.saveKycDocProof(this.clientId); navigateBack(); } }, error: () => { this.savingClient = false; } }); } /** * Save addresses from the India address step to Fineract address API. */ private saveClientAddresses(clientId: string) { const addressData = this.clientIndiaAddressStep.addressData; const typeNameMap: Record = { permanent: [ 'Permanent', 'Permanent Address' ], current: [ 'Residential', 'Current', 'Current / Residential', 'Communication' ], office: [ 'Office', 'Office / Work', 'Business', 'Work' ], correspondence: [ 'Correspondence', 'Mailing', 'Postal' ] }; this.clientsService.getClientAddressTemplate().subscribe({ next: (template: any) => { const typeOptions = template?.addressTypeIdOptions || []; for (const [ tab, addr ] of Object.entries(addressData)) { if (!addr || !(addr as any).addressLine1) continue; const possibleNames = typeNameMap[tab] || [tab]; let typeId = typeOptions.find((t: any) => possibleNames.some((n: string) => t.name?.toLowerCase().includes(n.toLowerCase())) )?.id; if (!typeId && typeOptions.length > 0) typeId = typeOptions[0].id; if (!typeId) continue; const payload: any = { addressLine1: (addr as any).addressLine1 || '', addressLine2: (addr as any).addressLine2 || '', addressLine3: '', townVillage: (addr as any).locality || '', postalCode: (addr as any).pinCode || '', city: (addr as any).city || '', countyDistrict: (addr as any).district || '', street: (addr as any).landmark || '', addressType: typeId, isActive: tab === 'permanent' }; // Resolve state name to stateProvinceId. Normalise both sides (lowercase, // collapse underscores + whitespace) so legacy UPPER_SNAKE rows still match // the postal-PIN API's "Madhya Pradesh" form and vice versa. See migration 0297. if ((addr as any).state && template?.stateProvinceIdOptions?.length) { const norm = (s: string) => (s || '') .toLowerCase() .replace(/[_\s]+/g, ' ') .trim(); const target = norm((addr as any).state); const stateMatch = template.stateProvinceIdOptions.find((s: any) => norm(s.name) === target); if (stateMatch) payload.stateProvinceId = stateMatch.id; } this.clientsService.createClientAddress(clientId, typeId.toString(), payload).subscribe({ error: () => {} }); } }, error: () => {} }); // Persist the selected proof of address as a client identifier (with attached files) this.saveAddressProof(clientId); } /** "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" — same convention the CKYC tab and * client-india-address-step's own "load existing" parsing both read back. */ private buildProofDescription(issueDate: string | null | undefined, expiryDate: string | null | undefined): string { const parts: string[] = []; if (issueDate) parts.push(`Issue date: ${issueDate}`); if (expiryDate) parts.push(`Expiry date: ${expiryDate}`); return parts.join(', '); } /** * Save the address-proof picked on the permanent tab as a client identifier * + upload each selected file as a document attached to that identifier. * Mirrors identities-tab.createIdentifierWithDocs. */ private saveAddressProof(clientId: string) { if (this.clientIndiaAddressStep?.existingAddressProofIdentifierId) { return; } const payload = this.clientIndiaAddressStep?.addressProofPayload; if (!payload?.proofId) return; const proofName = payload.proof?.name || 'Address Proof'; const identifierData: any = { documentTypeId: payload.proofId, status: 'ACTIVE', documentKey: payload.documentKey || `${proofName} - ${clientId}`, description: this.buildProofDescription(payload.issueDate, payload.expiryDate) }; this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({ next: (res: any) => { const identifierId = res?.resourceId; if (!identifierId) return; if (this.clientIndiaAddressStep) { this.clientIndiaAddressStep.existingAddressProofIdentifierId = identifierId.toString(); } for (const entry of payload.files) { const formData = new FormData(); formData.append('name', `${proofName} — ${entry.slot}`); formData.append('file', entry.file); this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} }); } }, error: () => {} }); } /** * Save the KYC document picked on the KYC tab as a client identifier * + upload each selected file as a document attached to that identifier. */ private saveKycDocProof(clientId: string) { const payload = this.clientKycStep?.kycDocPayload; // A document number corrected without re-scanning the document is still a change worth // saving, so this no longer requires files — it used to return here and the new number was // silently dropped. if (!payload?.proofId) return; const proofName = payload.proof?.name || 'KYC Document'; const uploadFiles = (identifierId: string) => { for (const entry of payload.files || []) { const formData = new FormData(); formData.append('name', `${proofName} — ${entry.slot}`); formData.append('file', entry.file); this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} }); } }; // A document already on file is updated in place, not posted again. // // Re-collecting a document used to create a second identifier carrying the same number and // the same scans, with nothing to say which of the two was current — the client in front of // this was left with two identical Aadhaar rows. The document the customer renewed is the // one already recorded, so its number and type are written back to that row and any new // scans are attached to it. const existingId = this.clientKycStep?.existingKycIdentifierId; if (existingId) { this.resolveDocumentTypeId(payload.proofId, proofName).subscribe((documentTypeId: number | null) => { const update: any = { documentKey: payload.documentKey || `${proofName} - ${clientId}`, status: 'ACTIVE' }; if (documentTypeId != null) { update.documentTypeId = documentTypeId; } this.clientsService.updateClientIdentifier(clientId, existingId, update).subscribe({ // The scans go up either way: failing to rewrite the number must not cost the images. next: () => uploadFiles(existingId), error: () => uploadFiles(existingId) }); }); return; } // The KYC step's chip-picker carries `id` as a doc-type CODE string (e.g. "AADHAAR"). // The backend ClientIdentifierRequest.documentTypeId is a Long, so a string proofId // trips Jackson → HttpMessageNotReadableException → 400. Resolve to numeric CodeValue // id by matching on name against /kyc/proof-types?type=id before posting. this.resolveDocumentTypeId(payload.proofId, proofName).subscribe((documentTypeId: number | null) => { if (documentTypeId == null) { return; } const identifierData: any = { documentTypeId, status: 'ACTIVE', documentKey: payload.documentKey || `${proofName} - ${clientId}`, description: 'KYC document' }; this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({ next: (res: any) => { const identifierId = res?.resourceId; if (!identifierId) return; if (this.clientKycStep) { this.clientKycStep.existingKycIdentifierId = identifierId.toString(); } uploadFiles(identifierId.toString()); }, error: () => {} }); }); } /** * Pass-through when proofId is already numeric (address proof). Otherwise resolve the * string code/name against the ID-proof CodeValues so the backend gets a proper Long. */ private resolveDocumentTypeId(proofId: any, proofName: string): Observable { if (typeof proofId === 'number' && Number.isFinite(proofId)) { return of(proofId); } return this.clientsService.getKycProofTypes('id').pipe( map((res: any) => { const list: any[] = res?.idProofs || []; const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); const target = cleanStr(proofName || proofId); // 1. Try exact code/name match let match = list.find((p: any) => { const pcode = cleanStr(p.code); const pname = cleanStr(p.name); return pcode === target || pname === target; }); // 2. Try loose contains/includes match if (!match) { match = list.find((p: any) => { const pcode = cleanStr(p.code); const pname = cleanStr(p.name); return pcode.includes(target) || pname.includes(target) || target.includes(pcode) || target.includes(pname); }); } // 3. Specific fallbacks for common keywords if (!match && target.includes('voter')) { match = list.find((p: any) => { const name = cleanStr(p.name); return name.includes('voter') || name.includes('epic'); }); } if (!match && target.includes('aadhaar')) { match = list.find((p: any) => cleanStr(p.name).includes('aadhaar')); } if (!match && target.includes('passport')) { match = list.find((p: any) => cleanStr(p.name).includes('passport')); } if (!match && target.includes('driving')) { match = list.find((p: any) => cleanStr(p.name).includes('drive') || cleanStr(p.name).includes('licen')); } if (!match && target.includes('pan')) { match = list.find((p: any) => cleanStr(p.name).includes('pan')); } return (match?.id ?? null) as number | null; }) ); } private saveClientFamilyMembers(clientId: string) { const members = this.clientFamilyMembersStep.clientFamilyMembers; for (const member of members) { // Skip existing members (already have an id from the API) if (member.id) continue; const marriageCertFile = member._marriageCertFile || null; const payload = { ...member }; // Remove internal/transient fields delete payload._linkedClient; delete payload._marriageCertFile; delete payload.dateFormat; delete payload.locale; payload.dateFormat = this.settingsService.dateFormat; payload.locale = this.settingsService.language.code; this.clientsService.addFamilyMember(clientId, payload).subscribe({ next: (res: any) => { // Upload marriage certificate if present if (marriageCertFile && res?.resourceId) { this.uploadFamilyMemberDocument(clientId, res.resourceId, marriageCertFile, 'Marriage Certificate'); } }, error: () => {} }); } } /** * Upload a document for a family member. */ private uploadFamilyMemberDocument(clientId: string, familyMemberId: string, file: File, docName: string) { const formData = new FormData(); formData.append('file', file); formData.append('name', docName); formData.append('description', docName); this.clientsService.uploadFamilyMemberDocument(clientId, familyMemberId, formData).subscribe({ error: () => {} }); } }