Files
cbsnexora/src/app/letter-mgmt/certificates/certificate-pdf.ts
T
nitesh 14c5f5070d Revert demo to pre-2026-08-06 state (rollback today's push)
Reverts all commits pushed to demo today (a5124ca3, 858c277a, a1a50ab8,
f72abb09, fcf66657) back to f8c532f7 as a single forward commit, without
rewriting history. All reverted content (Share Withdrawal/Closure feature,
TDS reports, KYC exception tracking, client-edit-email-dialog, form121 tab,
search-select) remains intact on dev and was verified identical before
this revert.
2026-08-06 13:39:40 +05:30

1368 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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/.
*/
/*
* Shared jsPDF builders for the certificate pages — Loan Interest + Share.
* Output mirrors the on-screen preview: serif body, coloured accent strip,
* framed inner border, summary table with accent-coloured total row, and a
* circular "common seal" stamp in the sign-off block.
*/
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';
import { Form60Record } from './form-60/form-60.component';
import { Form15Kind, Form15ghRecord } from './form-15gh/form-15gh.component';
import { HypothecationRecord } from './hypothecation/hypothecation.component';
import { NocRecord } from './noc/noc.component';
const BANK_NAME = 'Nexora Co-operative Bank Ltd.';
const BANK_ADDRESS = 'Head Office, 4th Floor, Nexora Tower, Mumbai 400051';
const BANK_REG = 'RBI Lic. No. NX-2018/MH/482';
const BANK_CONTACT = 'info@nexorabank.in · 022 6789 0000';
const C_TEXT: [number, number, number] = [
15,
23,
42
];
const C_MUTED: [number, number, number] = [
100,
116,
139
];
const C_BORDER: [number, number, number] = [
226,
232,
240
];
const C_LABEL_BG: [number, number, number] = [
248,
250,
252
];
const ACCENT_BLUE: [number, number, number] = [
30,
64,
175
];
const ACCENT_AMBER: [number, number, number] = [
217,
119,
6
];
const ACCENT_TEAL: [number, number, number] = [
13,
148,
136
];
const ACCENT_RED: [number, number, number] = [
185,
28,
28
];
const ACCENT_GREEN: [number, number, number] = [
21,
128,
61
];
function tint(rgb: [number, number, number]): [number, number, number] {
return [
Math.round(255 - (255 - rgb[0]) * 0.12),
Math.round(255 - (255 - rgb[1]) * 0.12),
Math.round(255 - (255 - rgb[2]) * 0.12)
];
}
function formatLongDate(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'long', year: 'numeric' });
}
function today(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
interface PdfCtx {
doc: jsPDF;
pageW: number;
pageH: number;
margin: number;
contentW: number;
accent: [number, number, number];
accentTint: [number, number, number];
}
function setText(c: PdfCtx, rgb: [number, number, number]): void {
c.doc.setTextColor(rgb[0], rgb[1], rgb[2]);
}
function setFill(c: PdfCtx, rgb: [number, number, number]): void {
c.doc.setFillColor(rgb[0], rgb[1], rgb[2]);
}
function setDraw(c: PdfCtx, rgb: [number, number, number]): void {
c.doc.setDrawColor(rgb[0], rgb[1], rgb[2]);
}
/** Common letterhead + title pill + meta row + decorative inner-frame border. */
function paintLetterhead(
c: PdfCtx,
titleText: string,
refLabel: string,
refValue: string,
rightLabel: string,
rightValue: string
): number {
// Accent top stripe
setFill(c, c.accent);
c.doc.rect(0, 0, c.pageW, 4, 'F');
// Decorative inner border (mirrors the page-frame in the preview dialog)
setDraw(c, C_BORDER);
c.doc.setLineWidth(0.3);
c.doc.rect(c.margin - 4, 14, c.pageW - (c.margin - 4) * 2, c.pageH - 14 - 16);
// Letterhead text
let y = 24;
c.doc.setFont('times', 'bold');
c.doc.setFontSize(19);
setText(c, c.accent);
c.doc.text(BANK_NAME, c.pageW / 2, y, { align: 'center' });
y += 5.5;
c.doc.setFont('helvetica', 'normal');
c.doc.setFontSize(9);
setText(c, C_MUTED);
c.doc.text(BANK_ADDRESS, c.pageW / 2, y, { align: 'center' });
y += 4;
c.doc.text(`${BANK_REG} · ${BANK_CONTACT}`, c.pageW / 2, y, { align: 'center' });
y += 5;
setDraw(c, C_BORDER);
c.doc.setLineWidth(0.3);
c.doc.line(c.margin, y, c.pageW - c.margin, y);
y += 7;
// Title pill (rounded rect, centered)
c.doc.setFont('helvetica', 'bold');
c.doc.setFontSize(11.5);
const pillTxt = titleText.toUpperCase();
const pillW = c.doc.getTextWidth(pillTxt) + 14;
const pillH = 8;
const pillX = (c.pageW - pillW) / 2;
setFill(c, c.accentTint);
c.doc.roundedRect(pillX, y - 5, pillW, pillH, pillH / 2, pillH / 2, 'F');
setText(c, c.accent);
c.doc.text(pillTxt, c.pageW / 2, y + 0.5, { align: 'center' });
y += 11;
// Meta strip
const colW = c.contentW / 3;
c.doc.setFont('helvetica', 'bold');
c.doc.setFontSize(8);
setText(c, C_MUTED);
c.doc.text('CERTIFICATE REFERENCE', c.margin, y);
c.doc.text('DATE OF ISSUE', c.margin + colW, y);
c.doc.text(rightLabel.toUpperCase(), c.margin + colW * 2, y);
y += 5;
setText(c, C_TEXT);
c.doc.setFont('courier', 'bold');
c.doc.setFontSize(10.5);
c.doc.text(refValue, c.margin, y + 0.5);
c.doc.setFont('helvetica', 'bold');
c.doc.setFontSize(10.5);
c.doc.text(formatLongDate(today()), c.margin + colW, y + 0.5);
// Right column gets a tinted pill (FY or Folio)
const rPillTxt = rightValue;
c.doc.setFont('helvetica', 'bold');
c.doc.setFontSize(9);
const rPillW = c.doc.getTextWidth(rPillTxt) + 10;
const rPillH = 6;
const rPillX = c.margin + colW * 2;
const rPillY = y - 4;
setFill(c, c.accentTint);
c.doc.roundedRect(rPillX, rPillY, rPillW, rPillH, rPillH / 2, rPillH / 2, 'F');
setText(c, c.accent);
c.doc.text(rPillTxt, rPillX + rPillW / 2, rPillY + rPillH / 2 + 1.3, { align: 'center' });
y += 7;
setDraw(c, C_BORDER);
c.doc.setLineDashPattern(
[
0.8,
0.8
],
0
);
c.doc.line(c.margin, y, c.pageW - c.margin, y);
c.doc.setLineDashPattern([], 0);
return y + 6;
}
/** Two-column addressee block: small "To," label on the left, customer info on the right. */
function paintAddressee(
c: PdfCtx,
y: number,
toLabel: string,
name: string,
customerId: string,
pan: string,
address: string
): number {
c.doc.setFont('helvetica', 'bold');
c.doc.setFontSize(11);
setText(c, C_TEXT);
c.doc.text(toLabel, c.margin, y);
const x = c.margin + 22;
c.doc.setFont('helvetica', 'bold');
c.doc.setFontSize(11);
c.doc.text(name, x, y);
y += 5;
c.doc.setFont('helvetica', 'normal');
c.doc.setFontSize(10);
setText(c, C_TEXT);
c.doc.text(`Customer ID: ${customerId} · PAN: ${pan}`, x, y);
y += 4.5;
const addrLines = c.doc.splitTextToSize(address, c.contentW - 22);
c.doc.text(addrLines, x, y);
y += addrLines.length * 4.5 + 4;
return y;
}
/** Body paragraph helper (Times, justified). */
function paintPara(c: PdfCtx, y: number, text: string): number {
c.doc.setFont('times', 'normal');
c.doc.setFontSize(11);
setText(c, C_TEXT);
const lines = c.doc.splitTextToSize(text, c.contentW);
c.doc.text(lines, c.margin, y, { align: 'justify', maxWidth: c.contentW });
return y + lines.length * 5 + 4;
}
/** 2x4 summary grid with accent-coloured total row. Rows are an array of
* [[labelL, valueL, labelR, valueR], ...] tuples plus an optional total. */
function paintSummary(
c: PdfCtx,
y: number,
rows: [string, string, string, string][],
total: { label: string; value: string }
): number {
const rowH = 8;
const colHalf = c.contentW / 2;
const labelW = 44;
const valueW = colHalf - labelW;
const drawCell = (x: number, w: number, txt: string, isLabel: boolean): void => {
if (isLabel) {
setFill(c, C_LABEL_BG);
c.doc.rect(x, y, w, rowH, 'F');
c.doc.setFont('helvetica', 'bold');
c.doc.setFontSize(7.5);
setText(c, C_MUTED);
c.doc.text(txt, x + 2.5, y + rowH / 2 + 1.3);
} else {
c.doc.setFont('helvetica', 'normal');
c.doc.setFontSize(10);
setText(c, C_TEXT);
c.doc.text(txt, x + 2.5, y + rowH / 2 + 1.3);
}
setDraw(c, C_BORDER);
c.doc.setLineWidth(0.2);
c.doc.rect(x, y, w, rowH);
};
for (const r of rows) {
drawCell(c.margin, labelW, r[0], true);
drawCell(c.margin + labelW, valueW, r[1], false);
drawCell(c.margin + colHalf, labelW, r[2], true);
drawCell(c.margin + colHalf + labelW, valueW, r[3], false);
y += rowH;
}
// Total row
setFill(c, c.accent);
c.doc.rect(c.margin, y, c.contentW, rowH + 1, 'F');
c.doc.setFont('helvetica', 'bold');
c.doc.setFontSize(10);
setText(
c,
[
255,
255,
255
]
);
c.doc.text(total.label, c.margin + 3, y + rowH / 2 + 2);
c.doc.text(total.value, c.margin + c.contentW - 3, y + rowH / 2 + 2, { align: 'right' });
return y + rowH + 6;
}
/** Sign-off + decorative circular seal on the right. */
function paintSignoff(c: PdfCtx, y: number, branch: string): number {
if (y > c.pageH - c.margin - 50) {
c.doc.addPage();
setFill(c, c.accent);
c.doc.rect(0, 0, c.pageW, 4, 'F');
y = c.margin;
}
// Signature line on the left
setDraw(c, C_MUTED);
c.doc.setLineWidth(0.3);
c.doc.line(c.margin, y + 14, c.margin + 70, y + 14);
c.doc.setFont('helvetica', 'bold');
c.doc.setFontSize(10);
setText(c, C_TEXT);
c.doc.text('Authorised Signatory', c.margin, y + 18);
c.doc.setFont('helvetica', 'normal');
c.doc.setFontSize(8.5);
setText(c, C_MUTED);
c.doc.text(branch, c.margin, y + 22.5);
// Decorative seal on the right (concentric circles)
const cx = c.pageW - c.margin - 16;
const cy = y + 10;
setDraw(c, c.accent);
c.doc.setLineWidth(0.6);
c.doc.circle(cx, cy, 14);
c.doc.setLineWidth(0.3);
c.doc.setLineDashPattern(
[
1,
1
],
0
);
c.doc.circle(cx, cy, 11);
c.doc.setLineDashPattern([], 0);
c.doc.setFont('helvetica', 'bold');
c.doc.setFontSize(7);
setText(c, c.accent);
c.doc.text('NEXORA', cx, cy - 1, { align: 'center' });
c.doc.setFontSize(5);
setText(c, C_MUTED);
c.doc.text('CO-OPERATIVE', cx, cy + 3, { align: 'center' });
c.doc.text('BANK', cx, cy + 5.5, { align: 'center' });
return y + 28;
}
/** Footer line at the bottom of the cert. */
function paintFooter(c: PdfCtx, y: number): void {
setDraw(c, C_BORDER);
c.doc.setLineDashPattern(
[
0.8,
0.8
],
0
);
c.doc.line(c.margin, y, c.pageW - c.margin, y);
c.doc.setLineDashPattern([], 0);
y += 4.5;
c.doc.setFont('helvetica', 'normal');
c.doc.setFontSize(8);
setText(c, C_MUTED);
c.doc.text(
`Issued by ${BANK_NAME} on ${formatLongDate(today())}. Computer generated — does not require physical signature for record purposes.`,
c.pageW / 2,
y,
{ align: 'center' }
);
}
function newCtx(accent: [number, number, number]): PdfCtx {
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
return {
doc,
pageW: doc.internal.pageSize.getWidth(),
pageH: doc.internal.pageSize.getHeight(),
margin: 20,
contentW: doc.internal.pageSize.getWidth() - 40,
accent,
accentTint: tint(accent)
};
}
/**
* 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);
let y = paintLetterhead(
c,
'Interest-Paid Certificate',
'CERTIFICATE REFERENCE',
`INT-${record.accountNo}-${fy.fy.slice(3)}`,
'Financial Year',
fy.fy
);
y = paintAddressee(c, y, 'To,', record.customerName, record.customerId, record.customerPan, record.customerAddress);
y = paintPara(
c,
y,
`This is to certify that ${record.customerName} (Customer ID ${record.customerId}) is availing the following loan facility from our bank, and the interest paid by the above customer during the financial year ${fy.fy} is as detailed below.`
);
y = paintSummary(
c,
y,
[
[
'LOAN ACCOUNT NO.',
record.accountNo,
'BRANCH',
record.branch
],
[
'LOAN TYPE',
record.loanType,
'DISBURSED ON',
formatLongDate(record.disbursementDate)
],
[
'SANCTIONED AMOUNT',
`Rs. ${record.sanctionedAmount.toLocaleString('en-IN')}`,
'INTEREST RATE',
`${record.interestRate}% p.a.`
],
[
'OPENING BALANCE',
`Rs. ${fy.openingBalance.toLocaleString('en-IN')}`,
'CLOSING BALANCE',
`Rs. ${fy.closingBalance.toLocaleString('en-IN')}`
],
[
'PRINCIPAL REPAID',
`Rs. ${fy.principalPaid.toLocaleString('en-IN')}`,
'INTEREST PAID',
`Rs. ${fy.interestPaid.toLocaleString('en-IN')}`
]
],
{
label: `TOTAL INTEREST PAID DURING ${fy.fy}`,
value: `Rs. ${fy.interestPaid.toLocaleString('en-IN')}`
}
);
y = paintPara(
c,
y,
'This certificate is issued at the request of the borrower for income-tax / financial record purposes and reflects the interest credited to the captioned loan account during the said financial year as per our books.'
);
y = paintSignoff(c, y + 6, record.branch);
paintFooter(c, y + 4);
return c.doc;
}
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`);
}
// ───────────────────── Share Certificate ─────────────────────
export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF {
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const pageW = doc.internal.pageSize.getWidth();
const pageH = doc.internal.pageSize.getHeight();
const margin = 20;
const contentW = pageW - 40;
const gold: [number, number, number] = [
197,
160,
89
]; // #c5a059
const darkBlue: [number, number, number] = [
11,
60,
93
]; // #0b3c5d
const textDark: [number, number, number] = [
44,
37,
32
]; // #2c2520
const textMuted: [number, number, number] = [
123,
111,
99
]; // #7b6f63
const lightBg: [number, number, number] = [
253,
252,
247
]; // #fdfcf7
const containerBg: [number, number, number] = [
250,
248,
245
]; // #faf8f5
// 1. Draw Cream Background (page body)
doc.setFillColor(containerBg[0], containerBg[1], containerBg[2]);
doc.rect(0, 0, pageW, pageH, 'F');
// 2. Draw Textured cream background inside the outer border
doc.setFillColor(lightBg[0], lightBg[1], lightBg[2]);
doc.rect(12, 12, pageW - 24, pageH - 24, 'F');
// 3. Draw Gold Double Borders (Outer and Inner)
doc.setDrawColor(gold[0], gold[1], gold[2]);
// Outer double line
doc.setLineWidth(1.2);
doc.rect(12, 12, pageW - 24, pageH - 24, 'D');
doc.setLineWidth(0.3);
doc.rect(13.8, 13.8, pageW - 27.6, pageH - 27.6, 'D');
// Thin interior border line
doc.setDrawColor(220, 209, 189); // light brown/grey
doc.setLineWidth(0.2);
doc.rect(17, 17, pageW - 34, pageH - 34, 'D');
// 4. Certificate Header
let y = 28;
doc.setFont('times', 'bold');
doc.setFontSize(22);
doc.setTextColor(darkBlue[0], darkBlue[1], darkBlue[2]);
doc.text('NEXORA CO-OPERATIVE BANK LTD.', pageW / 2, y, { align: 'center' });
y += 5;
doc.setFont('times', 'italic');
doc.setFontSize(10.5);
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
doc.text('Mumbai, Maharashtra, India', pageW / 2, y, { align: 'center' });
y += 4;
doc.setDrawColor(242, 237, 213); // light gold separator
doc.setLineWidth(1.5);
doc.line(22, y, pageW - 22, y);
y += 9;
doc.setFont('times', 'bold');
doc.setFontSize(24);
doc.setTextColor(gold[0], gold[1], gold[2]);
doc.text('SHARE CERTIFICATE', pageW / 2, y, { align: 'center' });
y += 5.5;
doc.setFont('times', 'bold');
doc.setFontSize(7.5);
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
doc.text('ISSUED UNDER THE COMMON SEAL OF THE BANK', pageW / 2, y, { align: 'center' });
y += 4;
doc.setDrawColor(242, 237, 213); // separator
doc.setLineWidth(0.3);
doc.line(22, y, pageW - 22, y);
// 5. Meta Row (Certificate No, Folio No, Date of Issue)
y += 7;
// Column 1: Certificate No
doc.setFont('helvetica', 'bold');
doc.setFontSize(7.5);
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
doc.text('CERTIFICATE NO.', 25, y);
doc.setFont('times', 'bold');
doc.setFontSize(10);
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
doc.text(r.certificateNo, 25, y + 4.5);
// Column 2: Folio No (Centered)
doc.setFont('helvetica', 'bold');
doc.setFontSize(7.5);
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
doc.text('FOLIO NO.', pageW / 2, y, { align: 'center' });
doc.setFont('times', 'bold');
doc.setFontSize(10);
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
doc.text(r.folioNo, pageW / 2, y + 4.5, { align: 'center' });
// Column 3: Date of Issue (Right Aligned)
doc.setFont('helvetica', 'bold');
doc.setFontSize(7.5);
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
doc.text('DATE OF ISSUE', pageW - 25, y, { align: 'right' });
doc.setFont('times', 'bold');
doc.setFontSize(10);
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
doc.text(formatLongDate(r.issueDate), pageW - 25, y + 4.5, { align: 'right' });
y += 8;
doc.setDrawColor(242, 237, 213);
doc.setLineWidth(0.3);
doc.line(22, y, pageW - 22, y);
// 6. Centered Body Section
y += 9;
const classLabel = r.shareClass === 'PREFERENCE' ? 'Preference' : 'Ordinary';
doc.setFont('times', 'normal');
doc.setFontSize(11);
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
const bodyText = `This is to certify that ${r.memberName} is the registered holder of ${r.shareCount.toLocaleString('en-IN')} (${classLabel}) shares of Rs. ${r.faceValue.toLocaleString('en-IN')} each, fully paid up, in Nexora Co-operative Bank Ltd., subject to the memorandum, articles of association, and bye-laws of the bank.`;
const bodyLines = doc.splitTextToSize(bodyText, pageW - 50);
for (const line of bodyLines) {
doc.text(line, pageW / 2, y, { align: 'center' });
y += 5.5;
}
// 7. ISSUED TO Box
y += 3;
const addrLines = doc.splitTextToSize(r.memberAddress || '', pageW - 60);
const boxH = 26 + addrLines.length * 4;
doc.setFillColor(253, 252, 247);
doc.setDrawColor(230, 225, 210);
doc.rect(25, y, pageW - 50, boxH, 'FD');
let boxY = y + 6;
doc.setFont('helvetica', 'bold');
doc.setFontSize(8);
doc.setTextColor(gold[0], gold[1], gold[2]);
doc.text('ISSUED TO:', 30, boxY);
boxY += 5.5;
doc.setFont('times', 'bold');
doc.setFontSize(13);
doc.setTextColor(darkBlue[0], darkBlue[1], darkBlue[2]);
doc.text(r.memberName, 30, boxY);
boxY += 5.5;
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
let idLine = `Member ID: ${r.memberId}`;
if (r.memberPan && r.memberPan !== 'Not Available' && r.memberPan !== '—') {
idLine += ` · PAN: ${r.memberPan}`;
}
doc.text(idLine, 30, boxY);
boxY += 5;
doc.setFontSize(8.5);
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
doc.text(addrLines, 30, boxY);
y += boxH + 8;
// 8. Details Grid
const tableW = pageW - 50;
const tableColW = tableW / 4;
const tableRowH = 8.5;
const drawTableCell = (
x: number,
tblY: number,
w: number,
h: number,
text: string,
isLabel: boolean,
align: 'left' | 'right' = 'left'
) => {
if (isLabel) {
doc.setFillColor(253, 252, 247);
doc.rect(x, tblY, w, h, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(8);
doc.setTextColor(gold[0], gold[1], gold[2]);
} else {
doc.setFont('times', 'bold');
doc.setFontSize(9.5);
doc.setTextColor(darkBlue[0], darkBlue[1], darkBlue[2]);
}
doc.setDrawColor(220, 209, 189);
doc.setLineWidth(0.25);
doc.rect(x, tblY, w, h, 'D');
doc.text(text, align === 'right' ? x + w - 3 : x + 3, tblY + h / 2 + 1.2, { align });
};
// Row 1
drawTableCell(25, y, tableColW, tableRowH, 'Branch', true);
drawTableCell(25 + tableColW, y, tableColW, tableRowH, r.branch || 'Mumbai Main Branch', false);
drawTableCell(25 + tableColW * 2, y, tableColW, tableRowH, 'Member Since', true);
drawTableCell(25 + tableColW * 3, y, tableColW, tableRowH, formatLongDate(r.joiningDate), false);
y += tableRowH;
// Row 2
drawTableCell(25, y, tableColW, tableRowH, 'Share Class', true);
drawTableCell(25 + tableColW, y, tableColW, tableRowH, classLabel, false);
drawTableCell(25 + tableColW * 2, y, tableColW, tableRowH, 'Date of Issue', true);
drawTableCell(25 + tableColW * 3, y, tableColW, tableRowH, formatLongDate(r.issueDate), false);
y += tableRowH;
// Row 3
drawTableCell(25, y, tableColW, tableRowH, 'Share Numbers', true);
drawTableCell(
25 + tableColW,
y,
tableColW,
tableRowH,
`${r.shareNoFrom.toLocaleString('en-IN')} – ${r.shareNoTo.toLocaleString('en-IN')}`,
false
);
drawTableCell(25 + tableColW * 2, y, tableColW, tableRowH, 'Dividend Paid', true);
drawTableCell(25 + tableColW * 3, y, tableColW, tableRowH, `Rs. ${r.dividendYtd.toLocaleString('en-IN')}`, false);
y += tableRowH;
// Row 4 (Highlight)
doc.setFillColor(darkBlue[0], darkBlue[1], darkBlue[2]);
doc.rect(25, y, tableColW * 2, tableRowH, 'F');
doc.rect(25 + tableColW * 2, y, tableColW * 2, tableRowH, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(8.5);
doc.setTextColor(255, 255, 255);
doc.text('TOTAL FACE VALUE', 28, y + tableRowH / 2 + 1.2);
doc.setFont('times', 'bold');
doc.setFontSize(11);
doc.text(`Rs. ${(r.shareCount * r.faceValue).toLocaleString('en-IN')}`, pageW - 28, y + tableRowH / 2 + 1.2, {
align: 'right'
});
y += tableRowH + 8;
// 9. Footer Text (Given under common seal...)
doc.setFont('times', 'italic');
doc.setFontSize(10.5);
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
const sealDay = new Date().getDate();
const sealMonthYear = new Date().toLocaleDateString('en-IN', { month: 'long', year: 'numeric' });
const sealPhrase = `Given under the common seal of the bank this ${sealDay} day of ${sealMonthYear}.`;
doc.text(sealPhrase, pageW / 2, y, { align: 'center' });
y += 8;
// 10. Seal Circle (left) and Signatory block (right)
const sealX = 50;
const sealY = y + 14;
doc.setDrawColor(gold[0], gold[1], gold[2]); // Gold
doc.setLineWidth(0.6);
doc.circle(sealX, sealY, 14, 'D');
doc.setLineWidth(0.3);
doc.setLineDashPattern(
[
1,
1
],
0
);
doc.circle(sealX, sealY, 11, 'D');
doc.setLineDashPattern([], 0);
doc.setFont('helvetica', 'bold');
doc.setFontSize(7.5);
doc.setTextColor(gold[0], gold[1], gold[2]);
doc.text('COMMON', sealX, sealY - 1, { align: 'center' });
doc.text('SEAL', sealX, sealY + 3.5, { align: 'center' });
// Signatory Line
doc.setDrawColor(textMuted[0], textMuted[1], textMuted[2]);
doc.setLineWidth(0.3);
doc.line(pageW - 75, y + 20, pageW - 25, y + 20);
doc.setFont('helvetica', 'bold');
doc.setFontSize(9.5);
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
doc.text('Authorised Signatory', pageW - 50, y + 25, { align: 'center' });
return doc;
}
export function downloadShareCertificatePdf(r: ShareCertificateRecord, dsc?: BankUserDsc): void {
const doc = buildShareCertificatePdf(r);
if (dsc) paintDscSignatureStamp(doc, dsc);
doc.save(`Share-Certificate-${r.certificateNo}.pdf`);
}
// ───────────────────── Balance Certificate ─────────────────────
export function buildBalanceCertificatePdf(r: BalanceCertificateRecord, asOnDate: string): jsPDF {
const c = newCtx(ACCENT_BLUE);
const typeLabel = ((): string => {
switch (r.accountType) {
case 'SAVINGS':
return 'Savings';
case 'CURRENT':
return 'Current';
case 'FIXED_DEPOSIT':
return 'Fixed Deposit';
case 'RD':
return 'Recurring Deposit';
case 'NRE':
return 'NRE Savings';
case 'NRO':
return 'NRO Savings';
}
})();
let y = paintLetterhead(
c,
'Balance Certificate',
'CERTIFICATE REFERENCE',
`BAL-${r.accountNo}-${asOnDate.replace(/-/g, '')}`,
'As-on Date',
formatLongDate(asOnDate)
);
y = paintAddressee(c, y, 'To,', r.customerName, r.customerId, r.customerPan, r.customerAddress);
y = paintPara(
c,
y,
`This is to certify that ${r.customerName} (Customer ID ${r.customerId}) maintains the following account with our bank, and the balance available in the said account as on ${formatLongDate(asOnDate)} is as detailed below.`
);
y = paintSummary(
c,
y,
[
[
'ACCOUNT NO.',
r.accountNo,
'BRANCH',
r.branch
],
[
'ACCOUNT TYPE',
typeLabel,
'PRODUCT',
r.productName
],
[
'ACCOUNT OPENED',
formatLongDate(r.openedOn),
'INTEREST RATE',
r.interestRate ? `${r.interestRate}% p.a.` : '—'
],
[
'AVAILABLE BALANCE',
`Rs. ${r.availableBalance.toLocaleString('en-IN')}`,
'ACCRUED INTEREST',
`Rs. ${r.accruedInterest.toLocaleString('en-IN')}`
]
],
{
label: `TOTAL LEDGER BALANCE AS ON ${formatLongDate(asOnDate)}`,
value: `Rs. ${r.ledgerBalance.toLocaleString('en-IN')}`
}
);
if (r.jointHolders) {
y = paintPara(c, y, `Joint holder(s): ${r.jointHolders}.`);
}
y = paintPara(
c,
y,
'This certificate is issued at the request of the customer for record purposes and reflects the balance in the said account as per our books as on the date mentioned above.'
);
y = paintSignoff(c, y + 6, r.branch);
paintFooter(c, y + 4);
return c.doc;
}
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`);
}
// ───────────────────── Form 60 ─────────────────────
const ID_PROOF_LABEL: Record<string, string> = {
AADHAAR: 'Aadhaar Card',
VOTER_ID: 'Voter Identity Card',
DRIVING_LICENSE: 'Driving License',
PASSPORT: 'Passport',
RATION: 'Ration Card'
};
export function buildForm60Pdf(r: Form60Record): jsPDF {
const c = newCtx(ACCENT_AMBER);
let y = paintLetterhead(
c,
'Form 97 Declaration',
'CERTIFICATE REFERENCE',
`F60-${r.customerId}`,
'PAN Status',
'NOT ALLOTTED'
);
y = paintPara(
c,
y,
'Declaration under Section 114B of the Income-tax Rules, 1962, to be made by a person who does not have a Permanent Account Number (PAN) and who enters into any transaction specified in rule 114B.'
);
// 14-row form structure rendered as a 2-col grid via paintSummary
y = paintSummary(
c,
y,
[
[
'1. FULL NAME',
r.customerName,
"2. FATHER'S / SPOUSE'S NAME",
r.fatherName
],
[
'3. DATE OF BIRTH',
formatLongDate(r.dob),
'4. CUSTOMER ID',
r.customerId
],
[
'5. ADDRESS',
r.customerAddress.length > 60 ? r.customerAddress.slice(0, 57) + '…' : r.customerAddress,
'6. BRANCH',
r.branch
],
[
'7. ID PROOF',
ID_PROOF_LABEL[r.idProofType] || r.idProofType,
'8. ID DOC NO.',
r.idProofNumber
],
[
'9. TRANSACTION TYPE',
r.transactionType.length > 50 ? r.transactionType.slice(0, 47) + '…' : r.transactionType,
'10. TRANSACTION DATE',
r.transactionDate ? formatLongDate(r.transactionDate) : '—'
],
[
'11. TRANSACTION AMOUNT',
`Rs. ${r.transactionAmount.toLocaleString('en-IN')}`,
'12. AGRI. INCOME',
`Rs. ${r.agriIncome.toLocaleString('en-IN')}`
]
],
{
label: 'ESTIMATED TOTAL INCOME (CURRENT FY)',
value: `Rs. ${r.estimatedIncome.toLocaleString('en-IN')}`
}
);
y = paintPara(
c,
y,
`I, ${r.customerName}, do hereby declare that the particulars given above are true and correct to the best of my knowledge and belief. I further declare that I have neither been assessed nor am liable to be assessed under the Income-tax Act, 1961, and that I have applied / not applied for a Permanent Account Number with the Income-tax Department.`
);
y = paintSignoff(c, y + 6, r.branch);
paintFooter(c, y + 4);
return c.doc;
}
export function downloadForm60Pdf(r: Form60Record, dsc?: BankUserDsc): void {
const doc = buildForm60Pdf(r);
if (dsc) paintDscSignatureStamp(doc, dsc);
doc.save(`Form-60-${r.customerId}.pdf`);
}
// ───────────────────── Form 15G / 15H ─────────────────────
export function buildForm15ghPdf(r: Form15ghRecord, kind: Form15Kind): jsPDF {
const c = newCtx(ACCENT_TEAL);
const todayDob = new Date();
const birth = new Date(r.dob);
let age = todayDob.getFullYear() - birth.getFullYear();
const m = todayDob.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && todayDob.getDate() < birth.getDate())) age--;
let y = paintLetterhead(
c,
`Form ${kind} Declaration`,
'CERTIFICATE REFERENCE',
`${kind}-${r.customerId}-${r.fy.replace(/\s+/g, '')}`,
'Form',
kind
);
y = paintPara(
c,
y,
`Declaration under Section ${
kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)'
} of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes — ${
kind === '15H'
? 'applicable to resident individuals aged 60 years or above'
: 'applicable to resident individuals (other than companies / firms) below 60 years'
}.`
);
y = paintSummary(
c,
y,
[
[
'1. NAME OF ASSESSEE',
r.customerName,
'2. PAN OF ASSESSEE',
r.customerPan
],
[
'3. STATUS',
'Individual',
'4. AGE',
`${age} years`
],
[
'5. PREVIOUS YEAR',
r.fy,
'6. CUSTOMER ID',
r.customerId
],
[
'7. ADDRESS',
r.customerAddress.length > 60 ? r.customerAddress.slice(0, 57) + '…' : r.customerAddress,
'8. BRANCH',
r.branch
],
[
'9. DEPOSIT PRINCIPAL',
`Rs. ${r.depositPrincipal.toLocaleString('en-IN')}`,
'10. ESTIMATED INTEREST',
`Rs. ${r.estimatedInterest.toLocaleString('en-IN')}`
],
[
'11. EST. TOTAL INCOME',
`Rs. ${r.estimatedIncome.toLocaleString('en-IN')}`,
'12. PREVIOUSLY ASSESSED?',
r.assessed ? 'Yes' : 'No'
]
],
{
label: 'TAX PAYABLE ON ESTIMATED INCOME',
value: 'NIL'
}
);
y = paintPara(
c,
y,
`I, ${r.customerName}, do hereby declare that the particulars given above are true and correct, and that the tax on my estimated total income for the previous year ${r.fy} will be NIL. I therefore request that no tax be deducted at source on the interest paid to me by the bank on the captioned deposits during the said previous year.`
);
y = paintSignoff(c, y + 6, r.branch);
paintFooter(c, y + 4);
return c.doc;
}
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`);
}
// ───────────────────── Hypothecation Certificate ─────────────────────
const ASSET_LABEL: Record<string, string> = {
VEHICLE: 'Vehicle',
PLANT_MACHINERY: 'Plant & Machinery',
STOCK_DEBTORS: 'Stock / Debtors',
GOLD: 'Gold / Bullion',
EQUIPMENT: 'Equipment'
};
export function buildHypothecationPdf(r: HypothecationRecord): jsPDF {
const c = newCtx(ACCENT_RED);
let y = paintLetterhead(
c,
'Hypothecation Certificate',
'CERTIFICATE REFERENCE',
r.certificateNo,
'Status',
r.status === 'NOC_ISSUED' ? 'NOC ISSUED' : r.status
);
y = paintAddressee(c, y, 'Issued To,', r.customerName, r.customerId, r.customerPan, r.customerAddress);
y = paintPara(
c,
y,
`This is to certify that the asset described below stands hypothecated to ${BANK_NAME} as security for the captioned loan facility extended to ${r.customerName}, and the bank holds the first and exclusive charge over the said asset until the loan is fully discharged.`
);
const rows: [string, string, string, string][] = [
[
'CERTIFICATE NO.',
r.certificateNo,
'LOAN ACCOUNT NO.',
r.accountNo
],
[
'LOAN TYPE',
r.loanType,
'BRANCH',
r.branch
],
[
'SANCTIONED AMOUNT',
`Rs. ${r.sanctionedAmount.toLocaleString('en-IN')}`,
'DISBURSED ON',
formatLongDate(r.disbursementDate)
],
[
'ASSET TYPE',
ASSET_LABEL[r.assetType] || r.assetType,
'IDENTIFIER',
r.assetIdentifier
],
[
'ASSET DESCRIPTION',
r.assetDescription.length > 38 ? r.assetDescription.slice(0, 35) + '…' : r.assetDescription,
'STATUS',
r.status === 'NOC_ISSUED' ? 'NOC ISSUED' : r.status
]
];
if (r.insuredValue > 0) {
rows.push([
'INSURED VALUE',
`Rs. ${r.insuredValue.toLocaleString('en-IN')}`,
'INSURANCE VALID TILL',
formatLongDate(r.insuranceValidTill)
]);
}
y = paintSummary(c, y, rows, {
label: 'OUTSTANDING PRINCIPAL',
value: `Rs. ${r.outstandingPrincipal.toLocaleString('en-IN')}`
});
y = paintPara(
c,
y,
'The lien on the said asset shall stand released only upon the bank issuing a No-Objection Certificate (NOC) after the entire outstanding under the captioned facility is paid in full, together with all interest, charges, and incidental costs.'
);
y = paintSignoff(c, y + 6, r.branch);
paintFooter(c, y + 4);
return c.doc;
}
export function downloadHypothecationPdf(r: HypothecationRecord, dsc?: BankUserDsc): void {
const doc = buildHypothecationPdf(r);
if (dsc) paintDscSignatureStamp(doc, dsc);
doc.save(`Hypothecation-${r.certificateNo}.pdf`);
}
// ───────────────────── No-Objection Certificate (NOC) ─────────────────────
const NOC_LOAN_TYPE_LABEL: Record<string, string> = {
AUTO: 'Auto Loan',
HOUSING: 'Housing Loan',
PERSONAL: 'Personal Loan',
EDUCATION: 'Education Loan',
GOLD: 'Gold Loan',
BUSINESS: 'Business Loan',
CC: 'Cash Credit',
OD: 'Overdraft'
};
const NOC_ASSET_LABEL: Record<string, string> = {
VEHICLE: 'Vehicle',
PROPERTY: 'Property',
GOLD: 'Gold / Bullion',
EQUIPMENT: 'Equipment',
STOCK_DEBTORS: 'Stock / Debtors',
NONE: 'Unsecured'
};
export function buildNocPdf(r: NocRecord): jsPDF {
const c = newCtx(ACCENT_GREEN);
const statusLabel = r.status === 'READY_TO_ISSUE' ? 'READY TO ISSUE' : r.status;
let y = paintLetterhead(c, 'No-Objection Certificate', 'NOC REFERENCE', r.nocNo, 'Status', statusLabel);
y = paintAddressee(c, y, 'Issued To,', r.customerName, r.customerId, r.customerPan, r.customerAddress);
y = paintPara(
c,
y,
`This is to certify that the captioned loan account in the name of ${r.customerName} has been duly closed, and the bank has NO OBJECTION to the release of the lien hereinafter recorded over the asset described below. All dues under the said facility, including principal, interest, and incidental charges, have been received by the bank as on the closure date.`
);
const rows: [string, string, string, string][] = [
[
'NOC NO.',
r.nocNo,
'LOAN ACCOUNT NO.',
r.accountNo
],
[
'LOAN TYPE',
NOC_LOAN_TYPE_LABEL[r.loanType] || r.loanType,
'BRANCH',
r.branch
],
[
'SANCTIONED AMOUNT',
`Rs. ${r.sanctionedAmount.toLocaleString('en-IN')}`,
'DISBURSED ON',
formatLongDate(r.disbursementDate)
]
];
if (r.closureDate) {
rows.push([
'CLOSURE DATE',
formatLongDate(r.closureDate),
'TOTAL PAID',
`Rs. ${r.totalPaid.toLocaleString('en-IN')}`
]);
}
if (r.assetType !== 'NONE') {
rows.push([
'ASSET TYPE',
NOC_ASSET_LABEL[r.assetType] || r.assetType,
'IDENTIFIER',
r.assetIdentifier
]);
rows.push([
'ASSET DESCRIPTION',
r.assetDescription.length > 38 ? r.assetDescription.slice(0, 35) + '…' : r.assetDescription,
'FORWARD TO',
r.forwardTo
? r.forwardTo.length > 38
? r.forwardTo.slice(0, 35) + '…'
: r.forwardTo
: '—'
]);
}
y = paintSummary(c, y, rows, {
label: r.assetType === 'NONE' ? 'ACCOUNT CLOSED — NO DUES OUTSTANDING' : 'LIEN RELEASED — NO OBJECTION TO TRANSFER',
value: 'NIL'
});
// Asset-type-specific closing paragraph
let closing: string;
switch (r.assetType) {
case 'VEHICLE':
closing =
'The Regional Transport Authority is hereby requested to delete the hypothecation endorsement of the bank from the registration certificate of the captioned vehicle.';
break;
case 'PROPERTY':
closing =
"The Sub-Registrar's office is hereby requested to delete the equitable / registered mortgage of the bank from the property records of the captioned property.";
break;
case 'GOLD':
closing =
'The pledged gold ornaments described above may be released to the customer / authorised representative after due verification at the branch counter.';
break;
case 'NONE':
closing =
'This certificate is issued at the request of the customer for record purposes and confirms that no dues are outstanding under the captioned facility.';
break;
default:
closing =
"The bank's lien over the captioned asset stands released with effect from the closure date, and the customer is free to deal with the said asset.";
break;
}
y = paintPara(c, y, closing);
if (r.status === 'ISSUED' && r.issuedOn) {
y = paintPara(c, y, `Issued on: ${formatLongDate(r.issuedOn)}${r.issuedBy ? ' · By: ' + r.issuedBy : ''}.`);
}
y = paintSignoff(c, y + 6, r.branch);
paintFooter(c, y + 4);
return c.doc;
}
export function downloadNocPdf(r: NocRecord, dsc?: BankUserDsc): void {
const doc = buildNocPdf(r);
if (dsc) paintDscSignatureStamp(doc, dsc);
doc.save(`NOC-${r.nocNo}.pdf`);
}