feat: KYC document update, FIU & AML recurring alerts integration, Sponsor DD revoke workflow, and TDS reports
This commit is contained in:
@@ -11,7 +11,7 @@ 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, of, Subject, takeUntil } from 'rxjs';
|
||||
import { catchError, forkJoin, map, of, Subject, takeUntil } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../../clients.service';
|
||||
@@ -30,6 +30,11 @@ 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';
|
||||
@@ -38,6 +43,15 @@ 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.
|
||||
@@ -149,9 +163,39 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
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();
|
||||
});
|
||||
@@ -190,6 +234,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1307,6 +1353,297 @@ I further confirm that:
|
||||
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;
|
||||
@@ -1365,5 +1702,11 @@ I further confirm that:
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user