Client-stepper: pre-fill existing KYC/address proofs in edit, fix identifier saves

Edit-mode now shows the previously selected KYC document type + uploaded file thumbnail
(loads from /clients/{id}/identifiers + /client_identifiers/{id}/documents). Same for
address proof on the India address step. Closes the gap where editing a client showed
an empty file picker even when proof had been uploaded on create.

Identifier-save hardened across all three callsites (create-client, edit-client,
identities-tab.createIdentifierWithDocs) - the KYC chip picker proofId is a CODE
string ('AADHAAR'), but backend ClientIdentifierRequest.documentTypeId is Long, which
Spring rejects with HttpMessageNotReadableException -> 400. resolveDocumentTypeId
helper looks up the code against /kyc/proof-types?type=id and substitutes the numeric
CodeValue id before posting. Skips silently for constitution-specific non-individual
docs that have no CodeValue mapping.

KYC payload normalises panNumber / enterprisePan / gstinNumber / cinNumber via
toUpperCase + whitespace strip so user-typed lowercase or trailing spaces don't trip
the backend regex (validation.msg.CLIENTKYC.panNumber.does.not.match.regexp).

State name match in client-address save now normalises both sides (lowercase, collapse
underscores + spaces) so legacy MADHYA_PRADESH resolves to 'Madhya Pradesh' returned
by api.postalpincode.in. Backed by tenant migration 0297.

Identities-tab resolver flattened - was returning identifiers before the per-row
forkJoin(getClientIdentificationDocuments) completed, so the view rendered empty
documents arrays. Now uses switchMap + forkJoin so the resolver awaits completion.

Also bundles concurrent work-in-progress on letter-mgmt, organization, users,
bank-calendar, shell, nominee/notes/identifier dialogs, and theming.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-20 12:40:58 +05:30
co-authored by Claude Opus 4.7
parent 325228c514
commit 83ea57ccf0
84 changed files with 10281 additions and 1194 deletions
@@ -20,6 +20,7 @@ import {
CertificatePreviewDialogData
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadBalanceCertificatePdf } from '../certificate-pdf';
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
export type BalanceAccountType = 'SAVINGS' | 'CURRENT' | 'FIXED_DEPOSIT' | 'RD' | 'NRE' | 'NRO';
@@ -60,6 +61,7 @@ export interface BalanceCertificateRecord {
export class BalanceCertificateComponent implements OnInit {
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
private dscSign = inject(DscSignService);
// ── Filters ──
search = '';
@@ -126,8 +128,14 @@ export class BalanceCertificateComponent implements OnInit {
}
downloadCertificate(r: BalanceCertificateRecord): void {
downloadBalanceCertificatePdf(r, this.asOnDate);
this.snack.open(`Balance Certificate (${r.accountNo}) downloaded.`, 'OK', { duration: 2400 });
this.dscSign.promptThenDownload(
{
title: 'Balance Certificate',
reference: `${r.accountNo} · ${r.customerName} · as on ${this.asOnDate}`,
filename: `Balance-Certificate-${r.accountNo}-${this.asOnDate}.pdf`
},
(dsc) => downloadBalanceCertificatePdf(r, this.asOnDate, dsc)
);
}
}
@@ -15,6 +15,8 @@
import { jsPDF } from 'jspdf';
import { BankUserDsc } from 'app/users/dsc/dsc.model';
import { FyInterestBreakup, LoanInterestRecord } from './loan-interest/loan-interest.component';
import { ShareCertificateRecord } from './share-certificate/share-certificate.component';
import { BalanceCertificateRecord } from './balance-certificate/balance-certificate.component';
@@ -394,6 +396,73 @@ function newCtx(accent: [number, number, number]): PdfCtx {
};
}
/**
* Paint a "Digitally Signed" stamp + auditor footer line on the final page of
* the given doc. Stamp = green-bordered rounded box on the bottom-right with
* a ✓ icon, the signer's common name, masked DSC serial, and signing time.
* Audit line = thin teal strip under the existing footer with the same info
* in monospace, so the certificate also reads cleanly when printed in B/W.
*
* Call this just before `doc.save(...)` — it doesn't add a new page.
*/
export function paintDscSignatureStamp(doc: jsPDF, dsc: BankUserDsc): void {
const pageW = doc.internal.pageSize.getWidth();
const pageH = doc.internal.pageSize.getHeight();
const margin = 20;
const signedAt = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const ts = `${signedAt.getFullYear()}-${pad(signedAt.getMonth() + 1)}-${pad(signedAt.getDate())} ${pad(signedAt.getHours())}:${pad(signedAt.getMinutes())}:${pad(signedAt.getSeconds())}`;
// ── Bottom-right rounded green stamp ──
const sW = 68;
const sH = 22;
const sX = pageW - margin - sW;
const sY = pageH - margin - sH - 4;
// Tint background + green border (slight rounding gives it a "stamped" feel)
doc.setFillColor(220, 252, 231); // green-100
doc.setDrawColor(21, 128, 61); // green-700
doc.setLineWidth(0.6);
doc.roundedRect(sX, sY, sW, sH, 2, 2, 'FD');
// ✓ icon + heading
doc.setFont('helvetica', 'bold');
doc.setFontSize(9);
doc.setTextColor(21, 128, 61);
doc.text('✓ DIGITALLY SIGNED', sX + 3, sY + 5);
// Signer name (Common Name)
doc.setFont('helvetica', 'bold');
doc.setFontSize(8.5);
doc.setTextColor(15, 23, 42);
doc.text(dsc.commonName, sX + 3, sY + 10);
// DSC serial (masked) + type
doc.setFont('courier', 'normal');
doc.setFontSize(7);
doc.setTextColor(71, 85, 105);
doc.text(`DSC ${dsc.dscSerialNoMasked}`, sX + 3, sY + 14);
doc.setFont('helvetica', 'normal');
doc.setFontSize(6.5);
doc.setTextColor(100, 116, 139);
doc.text(dsc.dscType, sX + 3, sY + 17.5);
doc.text(`Signed at ${ts}`, sX + 3, sY + 20.5);
// ── Audit footer line (thin teal strip just below the existing footer) ──
const fY = pageH - margin + 2;
doc.setFillColor(204, 251, 241); // teal-100
doc.rect(margin, fY, pageW - margin * 2, 4, 'F');
doc.setFont('courier', 'normal');
doc.setFontSize(7);
doc.setTextColor(13, 148, 136); // teal-600
doc.text(
`[SIGNED] ${dsc.commonName} · DSC ${dsc.dscSerialNoMasked} · Issued by ${dsc.issuedBy.slice(0, 38)} · ${ts}`,
margin + 2,
fY + 2.8
);
}
// ───────────────────── Loan Interest Certificate ─────────────────────
export function buildLoanInterestPdf(record: LoanInterestRecord, fy: FyInterestBreakup): jsPDF {
const c = newCtx(ACCENT_BLUE);
@@ -467,8 +536,9 @@ export function buildLoanInterestPdf(record: LoanInterestRecord, fy: FyInterestB
return c.doc;
}
export function downloadLoanInterestPdf(record: LoanInterestRecord, fy: FyInterestBreakup): void {
export function downloadLoanInterestPdf(record: LoanInterestRecord, fy: FyInterestBreakup, dsc?: BankUserDsc): void {
const doc = buildLoanInterestPdf(record, fy);
if (dsc) paintDscSignatureStamp(doc, dsc);
doc.save(`Interest-Certificate-${record.accountNo}-${fy.fy.replace(/\s+/g, '')}.pdf`);
}
@@ -552,8 +622,9 @@ export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF {
return c.doc;
}
export function downloadShareCertificatePdf(r: ShareCertificateRecord): void {
export function downloadShareCertificatePdf(r: ShareCertificateRecord, dsc?: BankUserDsc): void {
const doc = buildShareCertificatePdf(r);
if (dsc) paintDscSignatureStamp(doc, dsc);
doc.save(`Share-Certificate-${r.certificateNo}.pdf`);
}
@@ -645,8 +716,9 @@ export function buildBalanceCertificatePdf(r: BalanceCertificateRecord, asOnDate
return c.doc;
}
export function downloadBalanceCertificatePdf(r: BalanceCertificateRecord, asOnDate: string): void {
export function downloadBalanceCertificatePdf(r: BalanceCertificateRecord, asOnDate: string, dsc?: BankUserDsc): void {
const doc = buildBalanceCertificatePdf(r, asOnDate);
if (dsc) paintDscSignatureStamp(doc, dsc);
doc.save(`Balance-Certificate-${r.accountNo}-${asOnDate}.pdf`);
}
@@ -736,8 +808,9 @@ export function buildForm60Pdf(r: Form60Record): jsPDF {
return c.doc;
}
export function downloadForm60Pdf(r: Form60Record): void {
export function downloadForm60Pdf(r: Form60Record, dsc?: BankUserDsc): void {
const doc = buildForm60Pdf(r);
if (dsc) paintDscSignatureStamp(doc, dsc);
doc.save(`Form-60-${r.customerId}.pdf`);
}
@@ -830,8 +903,9 @@ export function buildForm15ghPdf(r: Form15ghRecord, kind: Form15Kind): jsPDF {
return c.doc;
}
export function downloadForm15ghPdf(r: Form15ghRecord, kind: Form15Kind): void {
export function downloadForm15ghPdf(r: Form15ghRecord, kind: Form15Kind, dsc?: BankUserDsc): void {
const doc = buildForm15ghPdf(r, kind);
if (dsc) paintDscSignatureStamp(doc, dsc);
doc.save(`Form-${kind}-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf`);
}
@@ -922,8 +996,9 @@ export function buildHypothecationPdf(r: HypothecationRecord): jsPDF {
return c.doc;
}
export function downloadHypothecationPdf(r: HypothecationRecord): void {
export function downloadHypothecationPdf(r: HypothecationRecord, dsc?: BankUserDsc): void {
const doc = buildHypothecationPdf(r);
if (dsc) paintDscSignatureStamp(doc, dsc);
doc.save(`Hypothecation-${r.certificateNo}.pdf`);
}
@@ -1052,7 +1127,8 @@ export function buildNocPdf(r: NocRecord): jsPDF {
return c.doc;
}
export function downloadNocPdf(r: NocRecord): void {
export function downloadNocPdf(r: NocRecord, dsc?: BankUserDsc): void {
const doc = buildNocPdf(r);
if (dsc) paintDscSignatureStamp(doc, dsc);
doc.save(`NOC-${r.nocNo}.pdf`);
}
@@ -20,6 +20,7 @@ import {
CertificatePreviewDialogData
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadForm15ghPdf } from '../certificate-pdf';
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
export type Form15Kind = '15G' | '15H';
export type Form15Status = 'FILED' | 'PENDING';
@@ -60,6 +61,7 @@ export interface Form15ghRecord {
export class Form15ghComponent implements OnInit {
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
private dscSign = inject(DscSignService);
// ── Filters ──
search = '';
@@ -141,8 +143,15 @@ export class Form15ghComponent implements OnInit {
}
downloadCertificate(r: Form15ghRecord): void {
downloadForm15ghPdf(r, this.formKind(r));
this.snack.open(`Form ${this.formKind(r)} for ${r.customerName} downloaded.`, 'OK', { duration: 2400 });
const kind = this.formKind(r);
this.dscSign.promptThenDownload(
{
title: `Form ${kind} Declaration`,
reference: `${r.customerId} · ${r.customerName} · ${r.fy}`,
filename: `Form-${kind}-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf`
},
(dsc) => downloadForm15ghPdf(r, kind, dsc)
);
}
}
@@ -20,6 +20,7 @@ import {
CertificatePreviewDialogData
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadForm60Pdf } from '../certificate-pdf';
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
export type Form60IdProofType = 'AADHAAR' | 'VOTER_ID' | 'DRIVING_LICENSE' | 'PASSPORT' | 'RATION';
@@ -59,6 +60,7 @@ export interface Form60Record {
export class Form60Component implements OnInit {
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
private dscSign = inject(DscSignService);
// ── Filters ──
search = '';
@@ -142,8 +144,14 @@ export class Form60Component implements OnInit {
});
return;
}
downloadForm60Pdf(r);
this.snack.open(`Form 60 for ${r.customerName} downloaded.`, 'OK', { duration: 2400 });
this.dscSign.promptThenDownload(
{
title: 'Form 60 Declaration',
reference: `${r.customerId} · ${r.customerName}`,
filename: `Form60-${r.customerId}.pdf`
},
(dsc) => downloadForm60Pdf(r, dsc)
);
}
}
@@ -20,6 +20,7 @@ import {
CertificatePreviewDialogData
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadHypothecationPdf } from '../certificate-pdf';
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
export type HypoAssetType = 'VEHICLE' | 'PLANT_MACHINERY' | 'STOCK_DEBTORS' | 'GOLD' | 'EQUIPMENT';
export type HypoStatus = 'ACTIVE' | 'CLOSED' | 'NOC_ISSUED';
@@ -63,6 +64,7 @@ export interface HypothecationRecord {
export class HypothecationCertificateComponent implements OnInit {
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
private dscSign = inject(DscSignService);
search = '';
assetFilter: '' | HypoAssetType = '';
@@ -133,10 +135,14 @@ export class HypothecationCertificateComponent implements OnInit {
}
downloadCertificate(r: HypothecationRecord): void {
downloadHypothecationPdf(r);
this.snack.open(`Hypothecation Certificate ${r.certificateNo} downloaded.`, 'OK', {
duration: 2400
});
this.dscSign.promptThenDownload(
{
title: 'Hypothecation Certificate',
reference: `${r.certificateNo} · ${r.accountNo} · ${r.customerName}`,
filename: `Hypothecation-Certificate-${r.certificateNo}.pdf`
},
(dsc) => downloadHypothecationPdf(r, dsc)
);
}
}
@@ -20,6 +20,7 @@ import {
CertificatePreviewDialogData
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadLoanInterestPdf } from '../certificate-pdf';
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
export type LoanProductType = 'HOUSING' | 'PERSONAL' | 'AUTO' | 'EDUCATION' | 'GOLD' | 'BUSINESS';
@@ -62,6 +63,7 @@ export interface LoanInterestRecord {
export class LoanInterestCertificateComponent implements OnInit {
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
private dscSign = inject(DscSignService);
// ── Filters ──
search = '';
@@ -161,8 +163,14 @@ export class LoanInterestCertificateComponent implements OnInit {
this.snack.open('No interest data on file for the selected financial year.', 'OK', { duration: 2500 });
return;
}
downloadLoanInterestPdf(l, fy);
this.snack.open(`Interest Certificate (${fy.fy}) downloaded.`, 'OK', { duration: 2400 });
this.dscSign.promptThenDownload(
{
title: 'Interest Certificate',
reference: `${l.accountNo} · ${l.customerName} · ${fy.fy}`,
filename: `Interest-Certificate-${l.accountNo}-${fy.fy.replace(/\s+/g, '')}.pdf`
},
(dsc) => downloadLoanInterestPdf(l, fy, dsc)
);
}
}
@@ -20,6 +20,7 @@ import {
CertificatePreviewDialogData
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadNocPdf } from '../certificate-pdf';
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
export type NocLoanType = 'AUTO' | 'HOUSING' | 'PERSONAL' | 'EDUCATION' | 'GOLD' | 'BUSINESS' | 'CC' | 'OD';
@@ -67,6 +68,7 @@ export interface NocRecord {
export class NocCertificateComponent implements OnInit {
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
private dscSign = inject(DscSignService);
// ── Filters ──
search = '';
@@ -158,8 +160,14 @@ export class NocCertificateComponent implements OnInit {
);
return;
}
downloadNocPdf(r);
this.snack.open(`NOC ${r.nocNo} downloaded.`, 'OK', { duration: 2400 });
this.dscSign.promptThenDownload(
{
title: 'No-Objection Certificate (NOC)',
reference: `${r.nocNo} · ${r.accountNo} · ${r.customerName}`,
filename: `NOC-${r.nocNo}.pdf`
},
(dsc) => downloadNocPdf(r, dsc)
);
}
}
@@ -20,6 +20,7 @@ import {
CertificatePreviewDialogData
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadShareCertificatePdf } from '../certificate-pdf';
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
export type ShareClass = 'ORDINARY' | 'PREFERENCE';
@@ -58,6 +59,7 @@ export interface ShareCertificateRecord {
export class ShareCertificateComponent implements OnInit {
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
private dscSign = inject(DscSignService);
// ── Filters ──
search = '';
@@ -132,8 +134,14 @@ export class ShareCertificateComponent implements OnInit {
}
downloadCertificate(r: ShareCertificateRecord): void {
downloadShareCertificatePdf(r);
this.snack.open(`Share Certificate ${r.certificateNo} downloaded.`, 'OK', { duration: 2400 });
this.dscSign.promptThenDownload(
{
title: 'Share Certificate',
reference: `${r.certificateNo} · Folio ${r.folioNo} · ${r.memberName}`,
filename: `Share-Certificate-${r.certificateNo}.pdf`
},
(dsc) => downloadShareCertificatePdf(r, dsc)
);
}
}