1751 lines
65 KiB
TypeScript
1751 lines
65 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 { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
|
|
import { MatDialog } from '@angular/material/dialog';
|
|
import { catchError, forkJoin, map, of, Subject, takeUntil } from 'rxjs';
|
|
|
|
/** Custom Services */
|
|
import { ClientsService } from '../../clients.service';
|
|
import { deriveKycExceptionFields } from '../../kyc-exception.util';
|
|
import {
|
|
deriveKycDocumentRenewal,
|
|
kycDocumentNeedsAttention,
|
|
KycDocumentRenewalStatus,
|
|
KYC_DOCUMENT_RENEWAL_YEARS
|
|
} from '../../kyc-document-renewal.util';
|
|
import {
|
|
PanDeclarationDialogComponent,
|
|
PanDeclarationResult
|
|
} from '../../shared/pan-declaration-dialog/pan-declaration-dialog.component';
|
|
import {
|
|
AadhaarReceiptDialogComponent,
|
|
AadhaarReceiptResult
|
|
} from '../../shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component';
|
|
import {
|
|
KycDocumentUpdateDialogComponent,
|
|
KycDocumentUpdateData,
|
|
KycDocumentUpdateResult
|
|
} from '../../shared/kyc-document-update-dialog/kyc-document-update-dialog.component';
|
|
import { Dates } from 'app/core/utils/dates';
|
|
import { jsPDF } from 'jspdf';
|
|
import { MatIcon } from '@angular/material/icon';
|
|
import { MatTooltip } from '@angular/material/tooltip';
|
|
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|
|
|
/** One side of the scanned KYC document, with the preview once it has been fetched. */
|
|
interface DocScanSide {
|
|
documentId: string;
|
|
/** The identifier row this scan hangs off; the two sides can sit on different rows. */
|
|
identifierId: string;
|
|
fileName: string;
|
|
previewUrl: string | null;
|
|
}
|
|
|
|
/**
|
|
* KYC Tab Component.
|
|
* Displays and manages KYC (Know Your Customer) details for an existing client.
|
|
* API: GET/POST /clients/{clientId}/kyc
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-kyc-tab',
|
|
templateUrl: './kyc-tab.component.html',
|
|
styleUrls: ['./kyc-tab.component.scss'],
|
|
standalone: true,
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
FormsModule,
|
|
MatIcon,
|
|
MatProgressSpinner,
|
|
MatTooltip,
|
|
FaIconComponent
|
|
]
|
|
})
|
|
export class KycTabComponent implements OnInit, OnDestroy {
|
|
private route = inject(ActivatedRoute);
|
|
private clientsService = inject(ClientsService);
|
|
private formBuilder = inject(UntypedFormBuilder);
|
|
private dateUtils = inject(Dates);
|
|
private dialog = inject(MatDialog);
|
|
|
|
private destroy$ = new Subject<void>();
|
|
|
|
clientId: string;
|
|
kycId: string | null = null;
|
|
kycData: any = null;
|
|
clientData: any = null;
|
|
clientDateOfBirth: string | null = null;
|
|
isNonIndividual = false;
|
|
loading = true;
|
|
saving = false;
|
|
isEditMode = false;
|
|
errorMessage: string | null = null;
|
|
|
|
/** Re-KYC flow state */
|
|
reKycUploading = false;
|
|
reKycCompleting = false;
|
|
reKycSuccess = false;
|
|
reKycError: string | null = null;
|
|
selectedDeclarationFile: File | null = null;
|
|
|
|
verifying: { pan: boolean; aadhaar: boolean; gstin: boolean } = {
|
|
pan: false,
|
|
aadhaar: false,
|
|
gstin: false
|
|
};
|
|
|
|
/** Verification status tracking for re-verify */
|
|
verificationStatus: { pan: string | null; aadhaar: string | null; gstin: string | null } = {
|
|
pan: null,
|
|
aadhaar: null,
|
|
gstin: null
|
|
};
|
|
|
|
verificationError: { pan: string | null; aadhaar: string | null; gstin: string | null } = {
|
|
pan: null,
|
|
aadhaar: null,
|
|
gstin: null
|
|
};
|
|
|
|
verificationDetails: { pan: any; aadhaar: any; gstin: any } = {
|
|
pan: null,
|
|
aadhaar: null,
|
|
gstin: null
|
|
};
|
|
|
|
/** Aadhaar OTP flow state */
|
|
aadhaarOtpSent = false;
|
|
aadhaarOtpReferenceId: string | null = null;
|
|
aadhaarOtp = '';
|
|
aadhaarOtpVerifying = false;
|
|
showAadhaarReVerify = false;
|
|
aadhaarFullNumber = '';
|
|
|
|
kycForm: UntypedFormGroup = this.formBuilder.group({
|
|
kycType: [
|
|
1,
|
|
[Validators.required]
|
|
],
|
|
riskCategory: [
|
|
1,
|
|
[Validators.required]
|
|
],
|
|
// Risk profile. Determinations about the customer, which the periodic risk review reads directly - a PEP or a
|
|
// sanctions match holds the rating at High whatever the period's activity looks like.
|
|
isPep: [false],
|
|
sanctionsMatch: [false],
|
|
adverseMediaMatch: [false],
|
|
declaredAnnualIncome: [null],
|
|
declaredAnnualTurnover: [null],
|
|
// The officially valid document, so a lapsed one can be re-collected on this form. The
|
|
// backend has always accepted these four; only the fields were missing.
|
|
kycDocumentName: [''],
|
|
kycDocumentNumber: [''],
|
|
kycDocIssueDate: [null],
|
|
kycDocExpiryDate: [null],
|
|
panNumber: [
|
|
'',
|
|
[Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
|
],
|
|
aadhaarNumber: [
|
|
'',
|
|
[Validators.pattern(/^\d{12}$/)]
|
|
],
|
|
gstinNumber: [
|
|
'',
|
|
[Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)]
|
|
],
|
|
remarks: [''],
|
|
isException: [false],
|
|
exceptionReason: [[]]
|
|
});
|
|
|
|
// ── Officially valid document scans ────────────────────────────────────────
|
|
|
|
/**
|
|
* The scans of the document named above, by side.
|
|
*
|
|
* The screen used to show the document's number and dates but not the image behind them, and
|
|
* sent the officer to the client edit screen to change it. Re-collecting an expiring document is
|
|
* the one thing this panel exists for, so the scan is shown and replaced here.
|
|
*/
|
|
docScans: { front: DocScanSide | null; back: DocScanSide | null } = { front: null, back: null };
|
|
|
|
/** The two sides, in display order, so the template loops rather than repeating itself. */
|
|
readonly docScanSlots: ('front' | 'back')[] = ['front', 'back'];
|
|
|
|
docScanOf(slot: 'front' | 'back') {
|
|
return this.docScans[slot];
|
|
}
|
|
|
|
/** Identifier row replacements are filed against. Null when the document has no identifier. */
|
|
private docIdentifierId: string | null = null;
|
|
/** Every identifier row carrying this document, since the sides can be split across them. */
|
|
private docIdentifierIds: string[] = [];
|
|
docScansLoading = false;
|
|
/** Which side is mid-upload, so only that button shows a spinner. */
|
|
docScanUploading: 'front' | 'back' | null = null;
|
|
docScanError: string | null = null;
|
|
docScanSuccess: string | null = null;
|
|
|
|
ngOnInit(): void {
|
|
this.route.parent.params.subscribe((params: any) => {
|
|
this.clientId = params['clientId'];
|
|
// Scans are matched against the document number on the KYC record, so they are loaded once
|
|
// that record has arrived rather than alongside it.
|
|
this.loadKycData();
|
|
this.loadClientDateOfBirth();
|
|
});
|
|
}
|
|
|
|
loadKycData(): void {
|
|
this.loading = true;
|
|
this.errorMessage = null;
|
|
this.clientsService
|
|
.getClientKyc(this.clientId)
|
|
.pipe(
|
|
catchError(() => {
|
|
this.loading = false;
|
|
return of(null);
|
|
})
|
|
)
|
|
.subscribe((data: any) => {
|
|
this.loading = false;
|
|
this.kycData = data;
|
|
if (data) {
|
|
const rawKycId = data.id ?? data.kycId ?? data.resourceId;
|
|
this.kycId = rawKycId != null ? rawKycId.toString() : null;
|
|
this.kycForm.patchValue({
|
|
kycType: data.kycType || 1,
|
|
riskCategory: data.riskCategory || 1,
|
|
isPep: !!data.isPep,
|
|
sanctionsMatch: !!data.sanctionsMatch,
|
|
adverseMediaMatch: !!data.adverseMediaMatch,
|
|
declaredAnnualIncome: data.declaredAnnualIncome ?? null,
|
|
declaredAnnualTurnover: data.declaredAnnualTurnover ?? null,
|
|
panNumber: data.panNumber || '',
|
|
// Aadhaar is stored masked on the server; user must re-enter
|
|
// the full 12-digit number to re-verify, so don't pre-fill.
|
|
aadhaarNumber: '',
|
|
gstinNumber: data.gstinNumber || '',
|
|
kycDocumentName: data.kycDocumentName || '',
|
|
kycDocumentNumber: data.kycDocumentNumber || '',
|
|
kycDocIssueDate: this.parseApiDate(data.kycDocIssueDate),
|
|
kycDocExpiryDate: this.parseApiDate(data.kycDocExpiryDate),
|
|
remarks: data.remarks || '',
|
|
isException: data.isException || false,
|
|
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
|
|
});
|
|
// Now that the document number is known, the scans filed under it can be found.
|
|
this.loadDocumentScans();
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Load client's date of birth and legal form from parent route resolved data */
|
|
loadClientDateOfBirth(): void {
|
|
this.route.parent.data.subscribe((data: any) => {
|
|
this.clientData = data?.clientViewData || null;
|
|
if (data?.clientViewData?.dateOfBirth) {
|
|
const dob = data.clientViewData.dateOfBirth;
|
|
if (Array.isArray(dob) && dob.length >= 3) {
|
|
this.clientDateOfBirth = `${dob[0]}-${String(dob[1]).padStart(2, '0')}-${String(dob[2]).padStart(2, '0')}`;
|
|
} else if (typeof dob === 'string') {
|
|
this.clientDateOfBirth = dob;
|
|
}
|
|
}
|
|
if (data?.clientViewData?.legalForm?.id === 2) {
|
|
this.isNonIndividual = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
toggleEditMode(): void {
|
|
this.isEditMode = !this.isEditMode;
|
|
this.resetOtpState();
|
|
if (this.isEditMode) {
|
|
// Clear Aadhaar so the user types a fresh 12-digit number for re-verify;
|
|
// server only returns the masked form which would fail validation.
|
|
this.kycForm.get('aadhaarNumber')?.reset('');
|
|
this.verificationStatus.aadhaar = null;
|
|
this.verificationStatus.pan = null;
|
|
this.verificationError.aadhaar = null;
|
|
this.verificationError.pan = null;
|
|
}
|
|
}
|
|
|
|
/** True when the stored record carries a signed declaration standing in for the PAN check. */
|
|
get panDeclarationTaken(): boolean {
|
|
return this.kycData?.panDeclarationTaken === true;
|
|
}
|
|
|
|
/**
|
|
* The entity's PAN, for the Enterprise PAN row a non-individual gets in place of the PAN row.
|
|
*
|
|
* Falls back to panNumber because that is where the KYC step actually stores it — the entity PAN
|
|
* is written to both fields on create, but a record saved before `enterprisePan` existed, or one
|
|
* saved from a screen that only writes panNumber, would otherwise show no PAN at all.
|
|
*/
|
|
get enterprisePanNumber(): string {
|
|
return this.kycData?.enterprisePan || this.kycData?.panNumber || '';
|
|
}
|
|
|
|
/** "Name", "Date of Birth", "Name and Date of Birth", or the not-checked-at-all case. */
|
|
get panDeclarationMismatchLabel(): string {
|
|
const fields: string = this.kycData?.panDeclarationMismatchFields;
|
|
if (!fields || fields === 'NONE') {
|
|
return 'PAN could not be verified';
|
|
}
|
|
return fields
|
|
.split(',')
|
|
.map((f) => (f === 'NAME' ? 'Name' : 'Date of Birth'))
|
|
.join(' and ');
|
|
}
|
|
|
|
/** True when the stored record carries a UIDAI enrolment slip standing in for the OTP check. */
|
|
get aadhaarReceiptTaken(): boolean {
|
|
return this.kycData?.aadhaarReceiptTaken === true;
|
|
}
|
|
|
|
// ── KYC document renewal ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Whether the OVD captured at onboarding is still current — see `deriveKycDocumentRenewal()`.
|
|
*
|
|
* The issue and expiry dates have always been captured on the KYC step but never read back, so
|
|
* a client onboarded on a ten-year-old licence looked identical to one onboarded yesterday.
|
|
*/
|
|
get kycDocumentRenewal(): KycDocumentRenewalStatus {
|
|
return deriveKycDocumentRenewal({
|
|
issueDate: this.parseApiDate(this.kycData?.kycDocIssueDate),
|
|
expiryDate: this.parseApiDate(this.kycData?.kycDocExpiryDate),
|
|
today: new Date()
|
|
});
|
|
}
|
|
|
|
/** Only raise the banner for a document that actually exists and actually needs action. */
|
|
get showKycDocumentAlert(): boolean {
|
|
return !!this.kycData?.kycDocumentName && kycDocumentNeedsAttention(this.kycDocumentRenewal.state);
|
|
}
|
|
|
|
/** Heading for the alert — the three states call for visibly different things. */
|
|
get kycDocumentAlertTitle(): string {
|
|
switch (this.kycDocumentRenewal.state) {
|
|
case 'EXPIRED':
|
|
return 'KYC Document Expired';
|
|
case 'EXPIRING_SOON':
|
|
return 'KYC Document Expires Soon';
|
|
default:
|
|
return 'KYC Document Due for Update';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The sentence staff act on. Each case names the date that triggered it, because "update the
|
|
* document" without saying why leaves the officer checking the form to work out which clock ran
|
|
* out.
|
|
*/
|
|
get kycDocumentAlertMessage(): string {
|
|
const status = this.kycDocumentRenewal;
|
|
const docName = this.kycData?.kycDocumentName || 'The KYC document';
|
|
// `formatDate()` only understands Fineract's [y, m, d] arrays; these are real Date objects.
|
|
const dueOn = status.dueOn ? this.dateUtils.formatDate(status.dueOn, 'dd MMMM yyyy') : '';
|
|
const days = status.daysRemaining ?? 0;
|
|
|
|
switch (status.state) {
|
|
case 'EXPIRED':
|
|
return `${docName} expired on ${dueOn} and is no longer a valid officially valid document. Collect a renewed document from the customer and update the KYC record.`;
|
|
case 'EXPIRING_SOON':
|
|
return days === 0
|
|
? `${docName} expires today (${dueOn}). Ask the customer for the renewed document before it lapses.`
|
|
: `${docName} expires on ${dueOn} — ${days} day${days === 1 ? '' : 's'} from now. Ask the customer for the renewed document before it lapses.`;
|
|
default:
|
|
return `${docName} was issued more than ${KYC_DOCUMENT_RENEWAL_YEARS} years ago and fell due for update on ${dueOn}. Re-collect the document from the customer and update the KYC record.`;
|
|
}
|
|
}
|
|
|
|
/** Drives the banner's colour — an expired document is a harder failure than a stale one. */
|
|
get kycDocumentAlertSeverity(): 'critical' | 'warning' {
|
|
return this.kycDocumentRenewal.state === 'EXPIRED' ? 'critical' : 'warning';
|
|
}
|
|
|
|
/**
|
|
* The date the document stops counting as current, whether or not one is printed on it.
|
|
*
|
|
* A Voter ID or NREGA card carries no expiry, but it is not valid forever for KYC purposes —
|
|
* the five-year rule is its expiry. Showing the derived date rather than a blank means every
|
|
* document answers "valid until when?", and the officer sees the same date the alert will fire on
|
|
* instead of having to add five years in their head.
|
|
*/
|
|
get kycDocumentExpiresOn(): string {
|
|
const dueOn = this.kycDocumentRenewal.dueOn;
|
|
return dueOn ? this.dateUtils.formatDate(dueOn, 'dd MMMM yyyy') : '';
|
|
}
|
|
|
|
/**
|
|
* True when the date above was calculated from the issue date rather than read off the document.
|
|
* Flagged in the UI so a derived date is never mistaken for one printed on the card.
|
|
*/
|
|
get kycDocumentExpiryIsDerived(): boolean {
|
|
return this.kycDocumentRenewal.reason === 'AGE';
|
|
}
|
|
|
|
/** Compact chip label for the document row in the verification table. */
|
|
get kycDocumentStatusLabel(): string {
|
|
switch (this.kycDocumentRenewal.state) {
|
|
case 'EXPIRED':
|
|
return 'Expired';
|
|
case 'EXPIRING_SOON':
|
|
return 'Expires Soon';
|
|
case 'DUE':
|
|
return 'Update Due';
|
|
case 'OK':
|
|
return 'Current';
|
|
default:
|
|
return '—';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Exception columns derived from verification status — see `deriveKycExceptionFields()`.
|
|
*
|
|
* A field counts as verified if it was verified in this session OR is already verified on the
|
|
* stored record; either way the customer has cleared that check. Verifying one of two
|
|
* outstanding items narrows the exception to the remaining one rather than closing it.
|
|
*/
|
|
/**
|
|
* The officially valid document, for the update payload.
|
|
*
|
|
* Only sent when a document is named, so an officer editing the risk category on a client who
|
|
* never had an OVD recorded does not silently blank the other three columns.
|
|
*/
|
|
private buildDocumentFields() {
|
|
const name = (this.kycForm.value.kycDocumentName || '').trim();
|
|
if (!name) {
|
|
return {};
|
|
}
|
|
const dateFormat = 'dd MMMM yyyy';
|
|
return {
|
|
kycDocumentName: name,
|
|
kycDocumentNumber: (this.kycForm.value.kycDocumentNumber || '').trim() || null,
|
|
kycDocIssueDate: this.kycForm.value.kycDocIssueDate
|
|
? this.dateUtils.formatDate(this.kycForm.value.kycDocIssueDate, dateFormat)
|
|
: null,
|
|
// Blank is meaningful: a passport expires, a PAN card does not.
|
|
kycDocExpiryDate: this.kycForm.value.kycDocExpiryDate
|
|
? this.dateUtils.formatDate(this.kycForm.value.kycDocExpiryDate, dateFormat)
|
|
: null
|
|
};
|
|
}
|
|
|
|
/** True while the banner is asking for a document, so the form shows the fields to supply it. */
|
|
get kycDocumentNeedsUpdate(): boolean {
|
|
return this.showKycDocumentAlert;
|
|
}
|
|
|
|
/** Today, as the latest an existing document can have been issued. */
|
|
get maxDate(): Date {
|
|
return new Date();
|
|
}
|
|
|
|
/**
|
|
* The customer's risk profile, as every save has to carry it.
|
|
*
|
|
* <p>
|
|
* Sent from every path that writes the KYC record rather than only the main Save, because the form holds one set of
|
|
* values: a save triggered from the PAN declaration or the Aadhaar receipt would otherwise write the record without
|
|
* these and silently clear a PEP flag the officer had just ticked.
|
|
*/
|
|
private buildRiskProfileFields() {
|
|
const v = this.kycForm.value;
|
|
return {
|
|
isPep: !!v.isPep,
|
|
sanctionsMatch: !!v.sanctionsMatch,
|
|
adverseMediaMatch: !!v.adverseMediaMatch,
|
|
// Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a
|
|
// customer who declared no income at all.
|
|
declaredAnnualIncome: v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
|
|
declaredAnnualTurnover:
|
|
v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover
|
|
};
|
|
}
|
|
|
|
private buildExceptionFields() {
|
|
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
|
|
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
|
|
|
|
return deriveKycExceptionFields({
|
|
panVerified,
|
|
// A signed declaration closes the PAN item by paperwork when the API never can.
|
|
panDeclarationTaken: this.panDeclarationTaken,
|
|
aadhaarVerified,
|
|
// An enrolment slip closes the Aadhaar item when there is no number to verify yet.
|
|
aadhaarReceiptTaken: this.aadhaarReceiptTaken,
|
|
aadhaarApplicable: !this.isNonIndividual,
|
|
hadException: this.kycData?.isException === true,
|
|
previousReason: this.kycData?.exceptionReason || null,
|
|
manualReasons: Array.isArray(this.kycForm.value.exceptionReason) ? this.kycForm.value.exceptionReason : [],
|
|
todayFormatted: this.dateUtils.formatDate(new Date(), 'dd MMMM yyyy')
|
|
});
|
|
}
|
|
|
|
saveKyc(): void {
|
|
if (this.kycForm.invalid) {
|
|
return;
|
|
}
|
|
this.saving = true;
|
|
this.errorMessage = null;
|
|
|
|
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
|
|
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : aadhaarRaw || null;
|
|
|
|
const payload: any = {
|
|
locale: 'en',
|
|
panNumber: this.kycForm.value.panNumber || null,
|
|
aadhaarNumberMasked: aadhaarMasked,
|
|
gstinNumber: this.kycForm.value.gstinNumber || null,
|
|
remarks: this.kycForm.value.remarks || null,
|
|
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
|
|
dateFormat: 'dd MMMM yyyy',
|
|
...this.buildDocumentFields(),
|
|
...this.buildRiskProfileFields(),
|
|
...this.buildExceptionFields()
|
|
};
|
|
|
|
if (!this.kycId) {
|
|
payload.kycType = this.kycForm.value.kycType || 1;
|
|
payload.riskCategory = this.kycForm.value.riskCategory || 1;
|
|
} else {
|
|
payload.riskCategory = this.kycForm.value.riskCategory || 1;
|
|
}
|
|
|
|
const save$ = this.kycId
|
|
? this.clientsService.updateClientKyc(this.clientId, this.kycId, payload)
|
|
: this.clientsService.saveClientKyc(this.clientId, payload);
|
|
|
|
save$.subscribe({
|
|
next: (response: any) => {
|
|
this.saving = false;
|
|
this.isEditMode = false;
|
|
if (!this.kycId && response?.resourceId) {
|
|
this.kycId = response.resourceId.toString();
|
|
}
|
|
this.loadKycData();
|
|
},
|
|
error: (err: any) => {
|
|
this.saving = false;
|
|
this.errorMessage =
|
|
err?.error?.developerMessage ||
|
|
err?.error?.defaultUserMessage ||
|
|
err?.message ||
|
|
'Failed to save KYC details.';
|
|
}
|
|
});
|
|
}
|
|
|
|
private saveKycBeforeVerify(callback: () => void, field: 'pan' | 'aadhaar' | 'gstin'): void {
|
|
if (this.isEditMode) {
|
|
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
|
|
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : aadhaarRaw || null;
|
|
const payload: any = {
|
|
locale: 'en',
|
|
panNumber: this.kycForm.value.panNumber || null,
|
|
aadhaarNumberMasked: aadhaarMasked,
|
|
gstinNumber: this.kycForm.value.gstinNumber || null,
|
|
remarks: this.kycForm.value.remarks || null,
|
|
riskCategory: this.kycForm.value.riskCategory || 1,
|
|
dateFormat: 'dd MMMM yyyy',
|
|
...this.buildRiskProfileFields(),
|
|
...this.buildExceptionFields()
|
|
};
|
|
|
|
this.clientsService.updateClientKyc(this.clientId, this.kycId, payload).subscribe({
|
|
next: () => {
|
|
this.loadKycData();
|
|
callback();
|
|
},
|
|
error: (err: any) => {
|
|
this.verifying[field] = false;
|
|
this.verificationStatus[field] = 'FAILED';
|
|
this.verificationError[field] =
|
|
err?.error?.defaultUserMessage || `Failed to update ${field.toUpperCase()} number before verification.`;
|
|
}
|
|
});
|
|
} else {
|
|
callback();
|
|
}
|
|
}
|
|
|
|
verifyPAN(): void {
|
|
if (!this.kycId) {
|
|
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
|
|
return;
|
|
}
|
|
this.verifying.pan = true;
|
|
this.verificationStatus.pan = 'PENDING';
|
|
this.verificationError.pan = null;
|
|
this.verificationDetails.pan = null;
|
|
|
|
this.saveKycBeforeVerify(() => {
|
|
this.clientsService
|
|
.verifyPAN(this.clientId, this.kycId)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe({
|
|
next: (response: any) => {
|
|
this.verifying.pan = false;
|
|
if (response?.changes?.panVerified) {
|
|
this.verificationStatus.pan = 'VERIFIED';
|
|
this.verificationDetails.pan = response.changes;
|
|
// A PAN that now verifies makes any earlier declaration moot. That write already
|
|
// re-derives the exception columns, so it settles the exception too.
|
|
if (this.panDeclarationTaken) {
|
|
this.writePanDeclaration(null);
|
|
} else {
|
|
this.syncExceptionAfterVerify();
|
|
}
|
|
} else {
|
|
this.verificationStatus.pan = 'FAILED';
|
|
this.verificationError.pan = 'PAN verification failed.';
|
|
}
|
|
},
|
|
error: (err: any) => {
|
|
this.verifying.pan = false;
|
|
this.verificationStatus.pan = 'FAILED';
|
|
this.verificationError.pan =
|
|
err?.error?.developerMessage ||
|
|
err?.error?.defaultUserMessage ||
|
|
err?.message ||
|
|
'PAN verification failed. Please try again.';
|
|
}
|
|
});
|
|
}, 'pan');
|
|
}
|
|
|
|
/**
|
|
* Records the customer declaration that stands in for a PAN check the client cannot pass.
|
|
*
|
|
* Offered for any PAN failure, not just a name/DOB mismatch — an officer facing an unreachable
|
|
* vendor is just as stuck as one facing a misspelt PAN card, and the same signed paperwork
|
|
* resolves both. When the failure was not a comparable mismatch there is no PAN card to compare
|
|
* against, so the dialog only asks why the declaration is being taken.
|
|
*/
|
|
takePanDeclaration(): void {
|
|
if (!this.kycId) {
|
|
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
|
|
return;
|
|
}
|
|
const failureMessage = this.verificationError.pan || 'PAN has not been verified.';
|
|
const mismatch = failureMessage.toLowerCase().includes('does not match');
|
|
|
|
this.dialog
|
|
.open(PanDeclarationDialogComponent, {
|
|
width: '720px',
|
|
maxWidth: '95vw',
|
|
disableClose: true,
|
|
data: {
|
|
submittedName: this.kycData?.clientDisplayName || this.clientData?.displayName || '',
|
|
submittedDob: this.clientDateOfBirth || '',
|
|
panNumber: this.kycForm.value.panNumber || this.kycData?.panNumber || '',
|
|
failureMessage,
|
|
// Only a reported name/DOB mismatch gives the officer something to compare against.
|
|
unverifiable: !mismatch,
|
|
existing: this.panDeclarationTaken
|
|
? {
|
|
mismatchFields: this.kycData?.panDeclarationMismatchFields || 'NONE',
|
|
nameOnPan: this.kycData?.panDeclarationNameOnPan || null,
|
|
dobOnPan: this.parseApiDate(this.kycData?.panDeclarationDobOnPan),
|
|
// The scan itself lives with the client's documents; the record keeps its name.
|
|
file: null,
|
|
fileName: this.kycData?.panDeclarationRemarks || ''
|
|
}
|
|
: null
|
|
}
|
|
})
|
|
.afterClosed()
|
|
.subscribe((result: PanDeclarationResult | undefined) => {
|
|
if (!result) {
|
|
return;
|
|
}
|
|
// The scan has to be filed against the client before the record says a declaration exists —
|
|
// otherwise the PAN exception is waived by a document nobody can produce.
|
|
if (result.file) {
|
|
this.uploadPanDeclarationFile(result.file, () => this.writePanDeclaration(result));
|
|
} else {
|
|
this.writePanDeclaration(result);
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Files the signed declaration under the client's documents, then continues with `onDone`. */
|
|
private uploadPanDeclarationFile(file: File, onDone: () => void): void {
|
|
this.saving = true;
|
|
this.errorMessage = null;
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('name', 'PAN Declaration');
|
|
formData.append(
|
|
'description',
|
|
`Signed PAN declaration for ${this.kycForm.value.panNumber || this.kycData?.panNumber || 'PAN'}`
|
|
);
|
|
|
|
this.clientsService
|
|
.uploadClientDocument(this.clientId, formData)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe({
|
|
next: () => {
|
|
this.saving = false;
|
|
onDone();
|
|
},
|
|
error: (err: any) => {
|
|
this.saving = false;
|
|
this.errorMessage =
|
|
err?.error?.defaultUserMessage ||
|
|
err?.error?.developerMessage ||
|
|
err?.message ||
|
|
'Could not upload the signed declaration. Please try again.';
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Officer-initiated removal — the PAN exception is raised again on this same save. */
|
|
removePanDeclaration(): void {
|
|
if (this.panDeclarationTaken) {
|
|
this.writePanDeclaration(null);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Persists (or clears) the declaration on the KYC record.
|
|
*
|
|
* `buildExceptionFields()` reads the declaration off `kycData`, so that has to be updated before
|
|
* the payload is built — otherwise the save would carry the declaration and the PAN exception at
|
|
* the same time, which is exactly the contradiction the declaration exists to prevent.
|
|
*/
|
|
private writePanDeclaration(declaration: PanDeclarationResult | null): void {
|
|
this.saving = true;
|
|
this.errorMessage = null;
|
|
|
|
const previous = {
|
|
panDeclarationTaken: this.kycData?.panDeclarationTaken,
|
|
panDeclarationMismatchFields: this.kycData?.panDeclarationMismatchFields,
|
|
panDeclarationNameOnPan: this.kycData?.panDeclarationNameOnPan,
|
|
panDeclarationDobOnPan: this.kycData?.panDeclarationDobOnPan,
|
|
panDeclarationRemarks: this.kycData?.panDeclarationRemarks,
|
|
panDeclarationTakenOn: this.kycData?.panDeclarationTakenOn
|
|
};
|
|
|
|
const dateFormat = 'dd MMMM yyyy';
|
|
const declarationFields = {
|
|
panDeclarationTaken: !!declaration,
|
|
panDeclarationMismatchFields: declaration?.mismatchFields || null,
|
|
panDeclarationNameOnPan: declaration?.nameOnPan || null,
|
|
panDeclarationDobOnPan: declaration?.dobOnPan
|
|
? this.dateUtils.formatDate(declaration.dobOnPan, dateFormat)
|
|
: null,
|
|
// Names the uploaded declaration filed under the client's documents.
|
|
panDeclarationRemarks: declaration?.fileName || null,
|
|
panDeclarationTakenOn: declaration ? this.dateUtils.formatDate(new Date(), dateFormat) : null
|
|
};
|
|
|
|
this.kycData = { ...(this.kycData || {}), ...declarationFields };
|
|
|
|
const payload: any = {
|
|
locale: 'en',
|
|
dateFormat,
|
|
riskCategory: this.kycForm.value.riskCategory || 1,
|
|
...declarationFields,
|
|
...this.buildRiskProfileFields(),
|
|
...this.buildExceptionFields()
|
|
};
|
|
|
|
this.clientsService
|
|
.updateClientKyc(this.clientId, this.kycId, payload)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe({
|
|
next: () => {
|
|
this.saving = false;
|
|
this.loadKycData();
|
|
},
|
|
error: (err: any) => {
|
|
this.saving = false;
|
|
// Put the record back so the tab never shows a declaration the server did not accept.
|
|
this.kycData = { ...(this.kycData || {}), ...previous };
|
|
this.errorMessage =
|
|
err?.error?.developerMessage ||
|
|
err?.error?.defaultUserMessage ||
|
|
err?.message ||
|
|
'Failed to save the PAN declaration.';
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Re-derives the exception columns once a verification has actually passed.
|
|
*
|
|
* `saveKycBeforeVerify()` writes the exception state *before* the vendor is called, when the
|
|
* check is by definition still outstanding — so a check that then succeeds leaves the record
|
|
* carrying an exception nobody closes. The KYC tab reads `panVerified` and shows "Verified"
|
|
* while the exceptions register keeps reading `isException`, which is why a client could sit in
|
|
* the Pending tab with every document verified.
|
|
*
|
|
* Called on each verification success. `buildExceptionFields()` already prefers the in-session
|
|
* `verificationStatus` over the stored record, so the freshly passed check counts even though
|
|
* `kycData` has not been reloaded yet; `hadException` still reads the stored value, which is
|
|
* what tells it to close the entry rather than leave every flag off.
|
|
*
|
|
* Verifying one of two outstanding items narrows the exception instead of closing it — that
|
|
* falls out of the derivation, so this needs no special case for it.
|
|
*/
|
|
private syncExceptionAfterVerify(): void {
|
|
if (!this.kycId) {
|
|
this.loadKycData();
|
|
return;
|
|
}
|
|
|
|
const exceptionFields = this.buildExceptionFields();
|
|
|
|
// Nothing recorded and nothing outstanding — no write would change anything.
|
|
if (this.kycData?.isException !== true && !exceptionFields.isException) {
|
|
this.loadKycData();
|
|
return;
|
|
}
|
|
|
|
this.clientsService
|
|
.updateClientKyc(this.clientId, this.kycId, {
|
|
locale: 'en',
|
|
dateFormat: 'dd MMMM yyyy',
|
|
riskCategory: this.kycForm.value.riskCategory || 1,
|
|
...exceptionFields
|
|
})
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe({
|
|
// The verification itself succeeded either way, so a failure here must not read as a
|
|
// failed check — surface it as the save problem it is and still refresh.
|
|
next: () => this.loadKycData(),
|
|
error: (err: any) => {
|
|
this.errorMessage =
|
|
err?.error?.developerMessage ||
|
|
err?.error?.defaultUserMessage ||
|
|
err?.message ||
|
|
'Verification succeeded but the KYC exception could not be updated.';
|
|
this.loadKycData();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Records the UIDAI enrolment receipt accepted in place of a verified Aadhaar.
|
|
*
|
|
* A customer who has applied for Aadhaar but has not been issued one has no number to put through
|
|
* the OTP flow, so the check can never pass. The slip closes the item instead.
|
|
*/
|
|
takeAadhaarReceipt(): void {
|
|
if (!this.kycId) {
|
|
this.verificationError.aadhaar = 'KYC record not found. Save KYC details first.';
|
|
return;
|
|
}
|
|
this.dialog
|
|
.open(AadhaarReceiptDialogComponent, {
|
|
width: '560px',
|
|
maxWidth: '95vw',
|
|
disableClose: true,
|
|
data: {
|
|
clientName: this.kycData?.clientDisplayName || this.clientData?.displayName || '',
|
|
reason:
|
|
this.verificationError.aadhaar ||
|
|
'Aadhaar has not been verified. Record the enrolment slip if the customer has applied but not yet received their Aadhaar.',
|
|
existing: this.aadhaarReceiptTaken
|
|
? {
|
|
enrolmentId: this.kycData?.aadhaarEnrolmentId || '',
|
|
enrolmentDate: this.parseApiDate(this.kycData?.aadhaarEnrolmentDate),
|
|
remarks: this.kycData?.aadhaarReceiptRemarks || ''
|
|
}
|
|
: null
|
|
}
|
|
})
|
|
.afterClosed()
|
|
.subscribe((result: AadhaarReceiptResult | undefined) => {
|
|
if (result) {
|
|
this.writeAadhaarReceipt(result);
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Officer-initiated removal — the Aadhaar exception is raised again on this same save. */
|
|
removeAadhaarReceipt(): void {
|
|
if (this.aadhaarReceiptTaken) {
|
|
this.writeAadhaarReceipt(null);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Persists (or clears) the enrolment receipt on the KYC record.
|
|
*
|
|
* `buildExceptionFields()` reads the receipt off `kycData`, so that has to be updated before the
|
|
* payload is built — otherwise the save would carry the receipt and the Aadhaar exception at the
|
|
* same time, the contradiction the receipt exists to prevent.
|
|
*/
|
|
private writeAadhaarReceipt(receipt: AadhaarReceiptResult | null): void {
|
|
this.saving = true;
|
|
this.errorMessage = null;
|
|
|
|
const previous = {
|
|
aadhaarReceiptTaken: this.kycData?.aadhaarReceiptTaken,
|
|
aadhaarEnrolmentId: this.kycData?.aadhaarEnrolmentId,
|
|
aadhaarEnrolmentDate: this.kycData?.aadhaarEnrolmentDate,
|
|
aadhaarReceiptRemarks: this.kycData?.aadhaarReceiptRemarks,
|
|
aadhaarReceiptTakenOn: this.kycData?.aadhaarReceiptTakenOn
|
|
};
|
|
|
|
const dateFormat = 'dd MMMM yyyy';
|
|
const receiptFields = {
|
|
aadhaarReceiptTaken: !!receipt,
|
|
aadhaarEnrolmentId: receipt?.enrolmentId || null,
|
|
aadhaarEnrolmentDate: receipt?.enrolmentDate
|
|
? this.dateUtils.formatDate(receipt.enrolmentDate, dateFormat)
|
|
: null,
|
|
aadhaarReceiptRemarks: receipt?.remarks || null,
|
|
aadhaarReceiptTakenOn: receipt ? this.dateUtils.formatDate(new Date(), dateFormat) : null
|
|
};
|
|
|
|
this.kycData = { ...(this.kycData || {}), ...receiptFields };
|
|
|
|
const payload: any = {
|
|
locale: 'en',
|
|
dateFormat,
|
|
riskCategory: this.kycForm.value.riskCategory || 1,
|
|
...receiptFields,
|
|
...this.buildRiskProfileFields(),
|
|
...this.buildExceptionFields()
|
|
};
|
|
|
|
this.clientsService
|
|
.updateClientKyc(this.clientId, this.kycId, payload)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe({
|
|
next: () => {
|
|
this.saving = false;
|
|
this.verificationError.aadhaar = null;
|
|
this.loadKycData();
|
|
},
|
|
error: (err: any) => {
|
|
this.saving = false;
|
|
// Put the record back so the tab never shows a receipt the server did not accept.
|
|
this.kycData = { ...(this.kycData || {}), ...previous };
|
|
this.errorMessage =
|
|
err?.error?.developerMessage ||
|
|
err?.error?.defaultUserMessage ||
|
|
err?.message ||
|
|
'Failed to save the Aadhaar enrolment receipt.';
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Fineract serialises dates as `[yyyy, M, d]`; the same field can also arrive as a string. */
|
|
private parseApiDate(raw: any): Date | null {
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
if (Array.isArray(raw) && raw.length >= 3) {
|
|
return new Date(raw[0], raw[1] - 1, raw[2]);
|
|
}
|
|
const parsed = new Date(raw);
|
|
return isNaN(parsed.getTime()) ? null : parsed;
|
|
}
|
|
|
|
/** Show Aadhaar input in summary view for re-verification */
|
|
startAadhaarReVerify(): void {
|
|
this.showAadhaarReVerify = true;
|
|
this.aadhaarFullNumber = '';
|
|
this.verificationError.aadhaar = null;
|
|
}
|
|
|
|
/** Abandons the OTP exchange, so a mis-click can be backed out of without reloading the tab. */
|
|
cancelAadhaarReVerify(): void {
|
|
this.resetOtpState();
|
|
this.verificationError.aadhaar = null;
|
|
}
|
|
|
|
/** Step 1: Send OTP to Aadhaar-linked mobile */
|
|
sendAadhaarOtp(): void {
|
|
const aadhaar = this.isEditMode ? this.kycForm?.get('aadhaarNumber')?.value : this.aadhaarFullNumber;
|
|
if (!aadhaar || aadhaar.length !== 12) {
|
|
this.verificationError.aadhaar = 'Please enter the full 12-digit Aadhaar number to generate OTP.';
|
|
return;
|
|
}
|
|
if (!this.kycId) {
|
|
this.verificationError.aadhaar = 'KYC record not found. Save KYC details first.';
|
|
return;
|
|
}
|
|
this.verifying.aadhaar = true;
|
|
this.verificationStatus.aadhaar = 'PENDING';
|
|
this.verificationError.aadhaar = null;
|
|
this.aadhaarOtpSent = false;
|
|
this.aadhaarOtp = '';
|
|
|
|
this.saveKycBeforeVerify(() => {
|
|
this.clientsService
|
|
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe({
|
|
next: (response: any) => {
|
|
this.verifying.aadhaar = false;
|
|
const refNumber =
|
|
response?.changes?.aadhaarRefNumber ||
|
|
response?.aadhaarRefNumber ||
|
|
response?.changes?.referenceNumber ||
|
|
response?.resourceId;
|
|
this.aadhaarOtpSent = true;
|
|
this.aadhaarOtpReferenceId = refNumber || null;
|
|
this.verificationStatus.aadhaar = 'PENDING';
|
|
},
|
|
error: (err: any) => {
|
|
this.verifying.aadhaar = false;
|
|
this.verificationStatus.aadhaar = 'FAILED';
|
|
this.verificationError.aadhaar =
|
|
err?.error?.developerMessage ||
|
|
err?.error?.defaultUserMessage ||
|
|
err?.message ||
|
|
'Failed to send OTP. Please try again.';
|
|
}
|
|
});
|
|
}, 'aadhaar');
|
|
}
|
|
|
|
/** Step 2: Verify OTP entered by user */
|
|
verifyAadhaarOtp(): void {
|
|
if (!this.kycId || !this.aadhaarOtp || this.aadhaarOtp.length < 4) {
|
|
return;
|
|
}
|
|
this.aadhaarOtpVerifying = true;
|
|
this.verificationError.aadhaar = null;
|
|
|
|
this.clientsService
|
|
.verifyAadhaarOtp(this.clientId, this.kycId, this.aadhaarOtp, this.aadhaarOtpReferenceId || '')
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe({
|
|
next: (response: any) => {
|
|
this.aadhaarOtpVerifying = false;
|
|
if (response?.changes?.aadhaarVerified) {
|
|
this.verificationStatus.aadhaar = 'VERIFIED';
|
|
this.verificationDetails.aadhaar = response.changes;
|
|
this.resetOtpState();
|
|
// Aadhaar has been issued and verified, so the enrolment slip has served its purpose —
|
|
// drop it rather than leave the record claiming the customer is still waiting.
|
|
if (this.aadhaarReceiptTaken) {
|
|
this.writeAadhaarReceipt(null);
|
|
} else {
|
|
this.syncExceptionAfterVerify();
|
|
}
|
|
} else {
|
|
this.verificationStatus.aadhaar = 'FAILED';
|
|
this.verificationError.aadhaar = 'OTP verification failed.';
|
|
this.resetOtpState();
|
|
}
|
|
},
|
|
error: (err: any) => {
|
|
this.aadhaarOtpVerifying = false;
|
|
this.verificationStatus.aadhaar = 'FAILED';
|
|
this.verificationError.aadhaar =
|
|
err?.error?.developerMessage ||
|
|
err?.error?.defaultUserMessage ||
|
|
err?.message ||
|
|
'OTP verification failed. Please try again.';
|
|
this.resetOtpState();
|
|
}
|
|
});
|
|
}
|
|
|
|
verifyGSTIN(): void {
|
|
if (!this.kycId) {
|
|
this.verificationError.gstin = 'KYC record not found. Save KYC details first.';
|
|
return;
|
|
}
|
|
this.verifying.gstin = true;
|
|
this.verificationStatus.gstin = 'PENDING';
|
|
this.verificationError.gstin = null;
|
|
this.verificationDetails.gstin = null;
|
|
|
|
this.saveKycBeforeVerify(() => {
|
|
this.clientsService
|
|
.verifyGSTIN(this.clientId, this.kycId)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe({
|
|
next: (response: any) => {
|
|
this.verifying.gstin = false;
|
|
if (response?.changes?.gstinVerified) {
|
|
this.verificationStatus.gstin = 'VERIFIED';
|
|
this.verificationDetails.gstin = response.changes;
|
|
this.loadKycData();
|
|
} else {
|
|
this.verificationStatus.gstin = 'FAILED';
|
|
this.verificationError.gstin = 'GSTIN verification failed.';
|
|
}
|
|
},
|
|
error: (err: any) => {
|
|
this.verifying.gstin = false;
|
|
this.verificationStatus.gstin = 'FAILED';
|
|
this.verificationError.gstin =
|
|
err?.error?.developerMessage ||
|
|
err?.error?.defaultUserMessage ||
|
|
err?.message ||
|
|
'GSTIN verification failed. Please try again.';
|
|
}
|
|
});
|
|
}, 'gstin');
|
|
}
|
|
|
|
formatDate(date: any): string {
|
|
if (!date) return '—';
|
|
let year: number, month: number, day: number;
|
|
if (Array.isArray(date) && date.length >= 3) {
|
|
[
|
|
year,
|
|
month,
|
|
day
|
|
] = date;
|
|
} else {
|
|
return date;
|
|
}
|
|
const months = [
|
|
'January',
|
|
'February',
|
|
'March',
|
|
'April',
|
|
'May',
|
|
'June',
|
|
'July',
|
|
'August',
|
|
'September',
|
|
'October',
|
|
'November',
|
|
'December'
|
|
];
|
|
return `${day} ${months[month - 1]} ${year}`;
|
|
}
|
|
|
|
private resetOtpState(): void {
|
|
this.aadhaarOtpSent = false;
|
|
this.aadhaarOtp = '';
|
|
this.aadhaarOtpReferenceId = null;
|
|
this.aadhaarOtpVerifying = false;
|
|
this.showAadhaarReVerify = false;
|
|
this.aadhaarFullNumber = '';
|
|
}
|
|
|
|
getStatusLabel(code: string): string {
|
|
if (!code) return '';
|
|
const part = code.includes('.') ? code.split('.').pop() : code;
|
|
return (part.charAt(0).toUpperCase() + part.slice(1)).replace(/_/g, ' ');
|
|
}
|
|
|
|
getStatusClass(code: string): string {
|
|
if (!code) return '';
|
|
const part = code.includes('.') ? code.split('.').pop() : code;
|
|
return part.toLowerCase();
|
|
}
|
|
|
|
getPanAadhaarStatusLabel(status: string): string {
|
|
switch (status) {
|
|
case 'Y':
|
|
return 'Linked (Seeded)';
|
|
case 'N':
|
|
return 'Not Linked';
|
|
case 'E':
|
|
return 'Exempt';
|
|
default:
|
|
return 'Pending Verification';
|
|
}
|
|
}
|
|
|
|
/** Generate Self Declaration PDF with KYC details */
|
|
printSelfDeclaration(): void {
|
|
const client = this.clientData || {};
|
|
const kyc = this.kycData || {};
|
|
const clientName = client.displayName || client.fullname || '';
|
|
const cifNumber = client.accountNo || client.cifNumber || this.clientId;
|
|
const dob = this.clientDateOfBirth ? this.formatDate(client.dateOfBirth) : '—';
|
|
const officeName = client.officeName || '';
|
|
const today = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'long', year: 'numeric' });
|
|
|
|
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
|
const pageWidth = doc.internal.pageSize.getWidth();
|
|
const margin = 20;
|
|
const contentWidth = pageWidth - margin * 2;
|
|
let y = 20;
|
|
|
|
// Helper
|
|
const addLine = (label: string, value: string, yPos: number): number => {
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setFontSize(10);
|
|
doc.text(label, margin, yPos);
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.text(value || '—', margin + 55, yPos);
|
|
return yPos + 7;
|
|
};
|
|
|
|
const drawHr = (yPos: number): number => {
|
|
doc.setDrawColor(200, 200, 200);
|
|
doc.setLineWidth(0.3);
|
|
doc.line(margin, yPos, pageWidth - margin, yPos);
|
|
return yPos + 5;
|
|
};
|
|
|
|
// ── Header ──
|
|
doc.setFillColor(15, 23, 42);
|
|
doc.rect(0, 0, pageWidth, 30, 'F');
|
|
doc.setTextColor(255, 255, 255);
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setFontSize(16);
|
|
doc.text('Self Declaration Form', pageWidth / 2, 13, { align: 'center' });
|
|
doc.setFontSize(9);
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.text('KYC / Re-KYC Compliance — As per RBI Master Direction on KYC', pageWidth / 2, 21, { align: 'center' });
|
|
doc.setTextColor(0, 0, 0);
|
|
|
|
y = 40;
|
|
|
|
// ── Branch / Date ──
|
|
doc.setFontSize(10);
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.text(`Branch: ${officeName}`, margin, y);
|
|
doc.text(`Date: ${today}`, pageWidth - margin, y, { align: 'right' });
|
|
y += 8;
|
|
y = drawHr(y);
|
|
|
|
// ── Section: Customer Details ──
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setFontSize(12);
|
|
doc.setTextColor(16, 116, 185);
|
|
doc.text('Customer Details', margin, y);
|
|
doc.setTextColor(0, 0, 0);
|
|
y += 8;
|
|
|
|
y = addLine('Customer Name:', clientName, y);
|
|
y = addLine('CIF Number:', cifNumber, y);
|
|
y = addLine('Date of Birth:', dob, y);
|
|
if (client.mobileNo) y = addLine('Mobile:', client.mobileNo, y);
|
|
if (client.emailAddress) y = addLine('Email:', client.emailAddress, y);
|
|
y += 3;
|
|
y = drawHr(y);
|
|
|
|
// ── Section: KYC Documents ──
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setFontSize(12);
|
|
doc.setTextColor(16, 116, 185);
|
|
doc.text('KYC Document Details', margin, y);
|
|
doc.setTextColor(0, 0, 0);
|
|
y += 8;
|
|
|
|
// PAN
|
|
y = addLine('PAN Number:', kyc.panNumber || '—', y);
|
|
// "Pending" would misdescribe a PAN that was checked, failed, and was settled by declaration —
|
|
// nothing is pending on that record, and the certificate has to say what actually happened.
|
|
y = addLine(
|
|
'PAN Status:',
|
|
kyc.panVerified ? 'Verified' : this.panDeclarationTaken ? 'Not Verified — Declaration Taken' : 'Pending',
|
|
y
|
|
);
|
|
if (kyc.panVerifiedOn) y = addLine('PAN Verified On:', this.formatDate(kyc.panVerifiedOn), y);
|
|
if (!kyc.panVerified && this.panDeclarationTaken) {
|
|
y = addLine('Declared Mismatch:', this.panDeclarationMismatchLabel, y);
|
|
if (kyc.panDeclarationNameOnPan) y = addLine('Name on PAN:', kyc.panDeclarationNameOnPan, y);
|
|
if (kyc.panDeclarationDobOnPan) {
|
|
y = addLine('DOB on PAN:', this.formatDate(kyc.panDeclarationDobOnPan), y);
|
|
}
|
|
if (kyc.panDeclarationTakenOn) {
|
|
y = addLine('Declaration Taken On:', this.formatDate(kyc.panDeclarationTakenOn), y);
|
|
}
|
|
}
|
|
|
|
// Aadhaar
|
|
y = addLine('Aadhaar Number:', kyc.aadhaarNumberMasked || '—', y);
|
|
// As with PAN: "Pending" would misdescribe a client who holds only an enrolment slip.
|
|
y = addLine(
|
|
'Aadhaar Status:',
|
|
kyc.aadhaarVerified ? 'Verified' : this.aadhaarReceiptTaken ? 'Not Verified — Enrolment Receipt' : 'Pending',
|
|
y
|
|
);
|
|
if (kyc.aadhaarVerifiedOn) y = addLine('Aadhaar Verified On:', this.formatDate(kyc.aadhaarVerifiedOn), y);
|
|
if (!kyc.aadhaarVerified && this.aadhaarReceiptTaken) {
|
|
if (kyc.aadhaarEnrolmentId) y = addLine('Enrolment ID:', kyc.aadhaarEnrolmentId, y);
|
|
if (kyc.aadhaarEnrolmentDate) {
|
|
y = addLine('Enrolment Date:', this.formatDate(kyc.aadhaarEnrolmentDate), y);
|
|
}
|
|
if (kyc.aadhaarReceiptTakenOn) {
|
|
y = addLine('Receipt Recorded On:', this.formatDate(kyc.aadhaarReceiptTakenOn), y);
|
|
}
|
|
}
|
|
|
|
// PAN-Aadhaar Link
|
|
const seedingStatus =
|
|
kyc.panAadhaarSeedingStatus === 'Y'
|
|
? 'Linked'
|
|
: kyc.panAadhaarSeedingStatus === 'N'
|
|
? 'Not Linked'
|
|
: kyc.panAadhaarSeedingStatus === 'E'
|
|
? 'Exempt'
|
|
: 'Pending';
|
|
y = addLine('PAN-Aadhaar Link:', seedingStatus, y);
|
|
|
|
// GSTIN (Non-Individual)
|
|
if (this.isNonIndividual && kyc.gstinNumber) {
|
|
y = addLine('GSTIN:', kyc.gstinNumber, y);
|
|
y = addLine('GSTIN Status:', kyc.gstinVerified ? 'Verified' : 'Pending', y);
|
|
}
|
|
|
|
// Enterprise PAN / CIN
|
|
if (this.isNonIndividual && kyc.enterprisePan) {
|
|
y = addLine('Enterprise PAN:', kyc.enterprisePan, y);
|
|
}
|
|
if (this.isNonIndividual && kyc.cinNumber) {
|
|
y = addLine('CIN:', kyc.cinNumber, y);
|
|
}
|
|
|
|
y += 3;
|
|
y = drawHr(y);
|
|
|
|
// ── Section: KYC Summary ──
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setFontSize(12);
|
|
doc.setTextColor(16, 116, 185);
|
|
doc.text('KYC Summary', margin, y);
|
|
doc.setTextColor(0, 0, 0);
|
|
y += 8;
|
|
|
|
y = addLine('KYC Type:', kyc.kycType === 1 ? 'Individual' : kyc.kycType === 2 ? 'Non-Individual' : '—', y);
|
|
y = addLine(
|
|
'Risk Category:',
|
|
kyc.riskCategory === 1 ? 'Low' : kyc.riskCategory === 2 ? 'Medium' : kyc.riskCategory === 3 ? 'High' : '—',
|
|
y
|
|
);
|
|
y = addLine('Initiated On:', this.formatDate(kyc.initiatedOn), y);
|
|
y = addLine('Last Verified:', this.formatDate(kyc.verifiedOn), y);
|
|
y = addLine('Expiry Date:', this.formatDate(kyc.expiryDate), y);
|
|
|
|
y += 3;
|
|
y = drawHr(y);
|
|
|
|
// ── Declaration Text ──
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setFontSize(12);
|
|
doc.setTextColor(16, 116, 185);
|
|
doc.text('Declaration', margin, y);
|
|
doc.setTextColor(0, 0, 0);
|
|
y += 8;
|
|
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.setFontSize(9.5);
|
|
const declarationText = `I, ${clientName || '_______________'}, hereby declare that the information provided above is true, correct and complete to the best of my knowledge and belief. I understand that furnishing false information is punishable under applicable laws. I authorize the Bank to verify the above details through appropriate channels as per RBI Master Direction on KYC (DBR.AML.BC.No.81/14.01.001/2015-16) and its subsequent amendments.
|
|
|
|
I further confirm that:
|
|
1. I am not a Politically Exposed Person (PEP) / relative of a PEP (unless declared otherwise).
|
|
2. The source of funds in my account is from legitimate sources.
|
|
3. I shall promptly notify the Bank of any change in the above information.
|
|
4. I have read and understood the Bank's terms and conditions regarding KYC compliance.`;
|
|
|
|
const lines = doc.splitTextToSize(declarationText, contentWidth);
|
|
doc.text(lines, margin, y);
|
|
y += lines.length * 4.5 + 10;
|
|
|
|
// ── Signature section ──
|
|
y = drawHr(y);
|
|
y += 5;
|
|
|
|
// Left: Client signature
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.setFontSize(9);
|
|
doc.text('________________________', margin, y + 15);
|
|
doc.text('Signature of Customer', margin, y + 21);
|
|
doc.text(`Name: ${clientName}`, margin, y + 27);
|
|
doc.text(`Date: _______________`, margin, y + 33);
|
|
doc.text(`Place: _______________`, margin, y + 39);
|
|
|
|
// Right: Bank official
|
|
doc.text('________________________', pageWidth - margin - 50, y + 15);
|
|
doc.text('Authorized Bank Official', pageWidth - margin - 50, y + 21);
|
|
doc.text('Name: _______________', pageWidth - margin - 50, y + 27);
|
|
doc.text('Designation: _______________', pageWidth - margin - 50, y + 33);
|
|
doc.text('Stamp & Seal', pageWidth - margin - 50, y + 39);
|
|
|
|
// ── Footer ──
|
|
const footerY = doc.internal.pageSize.getHeight() - 10;
|
|
doc.setFontSize(7);
|
|
doc.setTextColor(150, 150, 150);
|
|
doc.text('This is a system-generated document. Generated from CBS Nexora — KYC Module.', pageWidth / 2, footerY, {
|
|
align: 'center'
|
|
});
|
|
|
|
// Save
|
|
doc.save(`Self_Declaration_${cifNumber}_${clientName.replace(/\s/g, '_')}.pdf`);
|
|
}
|
|
|
|
// ── Document scans ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Finds the identifier the KYC document was filed under and loads its scans.
|
|
*
|
|
* The KYC record keeps the document's name and number but not the identifier id, so the two are
|
|
* matched on the number — that is what makes them the same document, and it survives the client
|
|
* holding several identifiers of the same type.
|
|
*/
|
|
private loadDocumentScans(): void {
|
|
if (!this.clientId) {
|
|
return;
|
|
}
|
|
this.docScansLoading = true;
|
|
this.clientsService
|
|
.getClientIdentifiers(this.clientId)
|
|
.pipe(catchError(() => of([] as any[])))
|
|
.subscribe((identifiers: any[]) => {
|
|
const wantedKey = (this.kycData?.kycDocumentNumber || '').toString().trim();
|
|
const wantedName = (this.kycData?.kycDocumentName || '').toString().trim().toLowerCase();
|
|
const norm = (s: any) => (s || '').toString().trim().toLowerCase();
|
|
|
|
// Every identifier carrying this document number, not just the first. The onboarding step
|
|
// files each side against its own identifier row, so this client's Aadhaar front sits on
|
|
// one row and the back on another under the same number - taking only the first match
|
|
// showed the front and reported the back as missing.
|
|
let matches = (identifiers || []).filter((i) => wantedKey && norm(i.documentKey) === norm(wantedKey));
|
|
if (!matches.length) {
|
|
matches = (identifiers || []).filter((i) => wantedName && norm(i.documentType?.name) === wantedName);
|
|
}
|
|
|
|
if (!matches.length) {
|
|
this.docScansLoading = false;
|
|
this.docIdentifierId = null;
|
|
this.docIdentifierIds = [];
|
|
this.docScans = { front: null, back: null };
|
|
return;
|
|
}
|
|
// Replacements are filed against the first row, so the sides stay together from here on.
|
|
this.docIdentifierId = String(matches[0].id);
|
|
this.docIdentifierIds = matches.map((m: any) => String(m.id));
|
|
this.fetchScansFor(this.docIdentifierIds);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sides are read off the document name, which is how they were written at upload
|
|
* ("<Proof> — Front" / "— Back"). Where the name says nothing, upload order stands in, front
|
|
* first, rather than leaving a scan the officer cannot see.
|
|
*/
|
|
private fetchScansFor(identifierIds: string[]): void {
|
|
forkJoin(
|
|
identifierIds.map((id) =>
|
|
this.clientsService.getClientIdentificationDocuments(id).pipe(
|
|
// Tagged with the row it came from, because the download call needs the parent id and
|
|
// the sides can be spread across more than one.
|
|
map((docs: any) => ((docs as any[]) || []).map((d) => ({ ...d, identifierId: id }))),
|
|
catchError(() => of([] as any[]))
|
|
)
|
|
)
|
|
).subscribe((perIdentifier: any[][]) => {
|
|
this.docScansLoading = false;
|
|
// This runs again after every replace, so the previews being thrown away are revoked
|
|
// rather than left holding their blobs.
|
|
for (const old of [this.docScans.front, this.docScans.back]) {
|
|
if (old?.previewUrl) {
|
|
URL.revokeObjectURL(old.previewUrl);
|
|
}
|
|
}
|
|
|
|
// Oldest first by document id, so the newest upload for a side is the last one written and
|
|
// therefore the one that stands. Identifier order cannot do this job: a replacement is filed
|
|
// against the first row while the original sits on the second, so reading the rows in order
|
|
// let a document uploaded months ago overwrite the scan collected a moment ago - the officer
|
|
// pressed Replace, saw "replaced", and the old image came straight back.
|
|
const docs = perIdentifier.flat().sort((a, b) => Number(a.id) - Number(b.id));
|
|
const sides: typeof this.docScans = { front: null, back: null };
|
|
const unlabelled: any[] = [];
|
|
|
|
const entryOf = (d: any): DocScanSide => ({
|
|
documentId: String(d.id),
|
|
identifierId: String(d.identifierId),
|
|
fileName: d.fileName || d.name,
|
|
previewUrl: null
|
|
});
|
|
|
|
for (const d of docs) {
|
|
const text = `${d.name || ''} ${d.fileName || ''}`.toLowerCase();
|
|
// Later uploads win, so a replaced side shows the new scan rather than the original.
|
|
if (text.includes('back')) {
|
|
sides.back = entryOf(d);
|
|
} else if (text.includes('front')) {
|
|
sides.front = entryOf(d);
|
|
} else {
|
|
unlabelled.push(d);
|
|
}
|
|
}
|
|
for (const d of unlabelled) {
|
|
if (!sides.front) {
|
|
sides.front = entryOf(d);
|
|
} else if (!sides.back) {
|
|
sides.back = entryOf(d);
|
|
}
|
|
}
|
|
|
|
this.docScans = sides;
|
|
for (const key of ['front', 'back'] as const) {
|
|
if (sides[key]) {
|
|
this.loadScanPreview(sides[key]!);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private loadScanPreview(side: DocScanSide): void {
|
|
this.clientsService
|
|
.downloadClientIdentificationDocument(side.identifierId, side.documentId)
|
|
.pipe(catchError(() => of(null)))
|
|
.subscribe((blob: Blob | null) => {
|
|
if (!blob || !blob.type?.startsWith('image/')) {
|
|
return;
|
|
}
|
|
// Revoked in ngOnDestroy; a preview left behind holds the blob in memory for the life of
|
|
// the tab.
|
|
side.previewUrl = URL.createObjectURL(blob);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Replaces one side with a freshly collected scan.
|
|
*
|
|
* The new file is uploaded under the same side name, which is what the readers here and on the
|
|
* client screen key on, so the latest upload becomes the scan on show. The old file is left in
|
|
* place deliberately: a KYC document that has been re-collected is a record of what was held
|
|
* before and when it changed, and destroying it would leave the audit trail with a gap.
|
|
*/
|
|
onDocScanSelected(event: Event, slot: 'front' | 'back'): void {
|
|
const input = event.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
// Cleared so choosing the same filename twice still fires a change event.
|
|
input.value = '';
|
|
if (!file || !this.docIdentifierId) {
|
|
return;
|
|
}
|
|
|
|
this.docScanError = null;
|
|
this.docScanSuccess = null;
|
|
this.docScanUploading = slot;
|
|
|
|
const label = slot === 'front' ? 'Front' : 'Back';
|
|
const docName = this.kycForm.get('kycDocumentName')?.value || this.kycData?.kycDocumentName || 'KYC document';
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('name', `${docName} — ${label}`);
|
|
formData.append('description', `Re-collected on ${new Date().toLocaleDateString('en-IN')}`);
|
|
|
|
this.clientsService.uploadClientIdentifierDocument(this.docIdentifierId, formData).subscribe({
|
|
next: () => {
|
|
this.docScanUploading = null;
|
|
this.docScanSuccess = `${label} side replaced.`;
|
|
// Re-read every row, not just the one written to: the other side still lives on its own.
|
|
this.fetchScansFor(this.docIdentifierIds);
|
|
},
|
|
error: (err: any) => {
|
|
this.docScanUploading = null;
|
|
this.docScanError =
|
|
err?.error?.defaultUserMessage || err?.error?.developerMessage || `Could not replace the ${label.toLowerCase()} side.`;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* A verified record only accepts an amendment with a reason on it, so the dialog has to ask for
|
|
* one. 300 is VERIFIED; the code form is checked too, since either can arrive on the payload.
|
|
*/
|
|
private get isKycVerified(): boolean {
|
|
return this.kycData?.kycStatus === 300 || this.kycData?.kycStatusCode === 'clientKycStatus.verified';
|
|
}
|
|
|
|
/**
|
|
* Re-collects the officially valid document from its own row.
|
|
*
|
|
* PAN and Aadhaar are each handled on their row; this one sent the officer into edit mode on the
|
|
* whole KYC form to do the same job. The dialog asks for the number, the dates and each side of
|
|
* the scan together, which is what re-collecting a document actually is.
|
|
*/
|
|
updateKycDocument(): void {
|
|
if (!this.kycId) {
|
|
this.docScanError = 'KYC record not found. Save KYC details first.';
|
|
return;
|
|
}
|
|
|
|
this.dialog
|
|
.open(KycDocumentUpdateDialogComponent, {
|
|
width: '640px',
|
|
maxWidth: '95vw',
|
|
disableClose: true,
|
|
data: {
|
|
documentName: this.kycData?.kycDocumentName || 'KYC document',
|
|
documentNumber: this.kycData?.kycDocumentNumber || '',
|
|
issueDate: this.parseApiDate(this.kycData?.kycDocIssueDate),
|
|
expiryDate: this.parseApiDate(this.kycData?.kycDocExpiryDate),
|
|
reason: this.showKycDocumentAlert ? this.kycDocumentAlertMessage : '',
|
|
currentFront: this.docScans.front?.previewUrl || null,
|
|
currentBack: this.docScans.back?.previewUrl || null,
|
|
requiresRemarks: this.isKycVerified
|
|
} as KycDocumentUpdateData
|
|
})
|
|
.afterClosed()
|
|
.subscribe((result: KycDocumentUpdateResult | undefined) => {
|
|
if (result) {
|
|
this.persistKycDocument(result);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Saves the corrected details, then uploads whichever sides were re-scanned.
|
|
*
|
|
* The details go first: they are the record, and a scan attached to a document whose number was
|
|
* never corrected would file the new image under the old identity.
|
|
*/
|
|
private persistKycDocument(result: KycDocumentUpdateResult): void {
|
|
this.saving = true;
|
|
this.docScanError = null;
|
|
this.docScanSuccess = null;
|
|
|
|
const dateFormat = 'dd MMMM yyyy';
|
|
const payload: any = {
|
|
locale: 'en',
|
|
dateFormat,
|
|
kycDocumentName: this.kycData?.kycDocumentName || null,
|
|
kycDocumentNumber: result.documentNumber || null,
|
|
kycDocIssueDate: result.issueDate ? this.dateUtils.formatDate(result.issueDate, dateFormat) : null,
|
|
kycDocExpiryDate: result.expiryDate ? this.dateUtils.formatDate(result.expiryDate, dateFormat) : null
|
|
};
|
|
// The server refuses an amendment to a verified record without one, and records it as the
|
|
// reason the document changed.
|
|
if (result.remarks) {
|
|
payload.remarks = result.remarks;
|
|
}
|
|
|
|
this.clientsService.updateClientKyc(this.clientId, this.kycId!, payload).subscribe({
|
|
next: () => {
|
|
this.saving = false;
|
|
this.docScanSuccess = 'Document updated.';
|
|
this.loadKycData();
|
|
this.uploadReplacedSides(result);
|
|
},
|
|
error: (err: any) => {
|
|
this.saving = false;
|
|
this.docScanError =
|
|
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not update the document.';
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Sends each re-scanned side through the same upload path the panel below uses. */
|
|
private uploadReplacedSides(result: KycDocumentUpdateResult): void {
|
|
if (!this.docIdentifierId) {
|
|
return;
|
|
}
|
|
const sides: { slot: 'front' | 'back'; file: File }[] = [];
|
|
if (result.front) {
|
|
sides.push({ slot: 'front', file: result.front });
|
|
}
|
|
if (result.back) {
|
|
sides.push({ slot: 'back', file: result.back });
|
|
}
|
|
if (!sides.length) {
|
|
return;
|
|
}
|
|
|
|
const docName = this.kycData?.kycDocumentName || 'KYC document';
|
|
forkJoin(
|
|
sides.map(({ slot, file }) => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('name', `${docName} — ${slot === 'front' ? 'Front' : 'Back'}`);
|
|
formData.append('description', `Re-collected on ${new Date().toLocaleDateString('en-IN')}`);
|
|
return this.clientsService
|
|
.uploadClientIdentifierDocument(this.docIdentifierId!, formData)
|
|
.pipe(catchError(() => of(null)));
|
|
})
|
|
).subscribe(() => {
|
|
this.docScanSuccess = 'Document and scan updated.';
|
|
this.fetchScansFor(this.docIdentifierIds);
|
|
});
|
|
}
|
|
|
|
/** Handle file selection for signed declaration upload */
|
|
onDeclarationFileSelected(event: Event): void {
|
|
const input = event.target as HTMLInputElement;
|
|
if (input.files?.length) {
|
|
this.selectedDeclarationFile = input.files[0];
|
|
this.reKycError = null;
|
|
}
|
|
}
|
|
|
|
/** Upload signed declaration and complete re-KYC */
|
|
completeReKyc(): void {
|
|
if (!this.selectedDeclarationFile || !this.clientId || !this.kycId) return;
|
|
|
|
this.reKycCompleting = true;
|
|
this.reKycError = null;
|
|
this.reKycSuccess = false;
|
|
|
|
// Step 1: Upload the signed declaration as a client document
|
|
const formData = new FormData();
|
|
formData.append('file', this.selectedDeclarationFile);
|
|
formData.append('name', 'KYC Self Declaration');
|
|
formData.append(
|
|
'description',
|
|
`Signed self-declaration form uploaded on ${new Date().toLocaleDateString('en-IN')}`
|
|
);
|
|
|
|
this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({
|
|
next: () => {
|
|
// Step 2: Call re-KYC completion API to update verified/expiry dates
|
|
this.clientsService.completeReKyc(this.clientId, this.kycId!).subscribe({
|
|
next: () => {
|
|
this.reKycCompleting = false;
|
|
this.reKycSuccess = true;
|
|
this.selectedDeclarationFile = null;
|
|
this.loadKycData(); // Refresh to show updated dates
|
|
},
|
|
error: (err: any) => {
|
|
this.reKycCompleting = false;
|
|
this.reKycError =
|
|
err?.error?.defaultUserMessage ||
|
|
err?.error?.developerMessage ||
|
|
'Failed to complete re-KYC. Please try again.';
|
|
}
|
|
});
|
|
},
|
|
error: (err: any) => {
|
|
this.reKycCompleting = false;
|
|
this.reKycError =
|
|
err?.error?.defaultUserMessage ||
|
|
err?.error?.developerMessage ||
|
|
'Failed to upload declaration. Please try again.';
|
|
}
|
|
});
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.destroy$.next();
|
|
this.destroy$.complete();
|
|
// Object URLs are held by the browser until revoked, so the scan blobs would outlive the tab.
|
|
for (const side of [this.docScans.front, this.docScans.back]) {
|
|
if (side?.previewUrl) {
|
|
URL.revokeObjectURL(side.previewUrl);
|
|
}
|
|
}
|
|
}
|
|
}
|