/** * 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/. */ /* * Builds the Consolidated Balance Sheet PDF, laid out like the bank's printed report: liabilities * on the left, assets on the right, each top-level group a named block with its accounts listed * under it and a subtotal in the TOTAL column. * * Drawn directly with jsPDF rather than printed from the DOM: window.print() carries the whole app * shell (sidenav, toolbar, ticker) into the output and gives no control over where a page breaks, * which is what made the printed sheet unreadable. Here each column is paginated on its own, the * way the printed report does it — the left side can run onto page 2 while the right side is still * on page 1 — and every page repeats the heading. */ import { jsPDF } from 'jspdf'; export interface BalanceSheetPdfNode { name: string; glCode: string; usage: 'HEADER' | 'DETAIL'; balance: number; children: BalanceSheetPdfNode[]; } export interface BalanceSheetPdfSide { label: string; total: number; groups: { classification: string; total: number; nodes: BalanceSheetPdfNode[] }[]; } export interface BalanceSheetPdfInput { bankName: string; officeLabel: string; /** yyyy-MM-dd */ asOnDate: string; creditSide: BalanceSheetPdfSide; debitSide: BalanceSheetPdfSide; } // ── Page geometry, in mm on A4 portrait ───────────────────────────────────── const PAGE_W = 210; const PAGE_H = 297; const MARGIN = 8; const COL_W = (PAGE_W - MARGIN * 2) / 2; const COL_X = [ MARGIN, MARGIN + COL_W ]; /** Right edges of the BALANCE and TOTAL columns, relative to a column's left edge. */ const BALANCE_R = COL_W - 26; const TOTAL_R = COL_W - 2; /** Name text wraps within this width so it never runs under the BALANCE column. */ const NAME_W = COL_W - 30; const BODY_TOP = 36; /** Leaves room under the body for the sign-off row and the print-date footer. */ const BODY_BOTTOM = PAGE_H - 21; const LINE_H = 3.1; const INK: [number, number, number] = [ 20, 20, 20 ]; const MUTED: [number, number, number] = [ 90, 90, 90 ]; type Line = | { kind: 'block'; text: string } | { kind: 'subhead'; text: string; depth: number } | { kind: 'detail'; lines: string[]; amount: string; depth: number } | { kind: 'subsubtotal'; amount: string } | { kind: 'subtotal'; amount: string } | { kind: 'standalone'; lines: string[]; amount: string } | { kind: 'grand'; amount: string } | { kind: 'gap' }; function money(value: number): string { return new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value ?? 0); } function ddmmyyyy(iso: string): string { if (!iso) { return ''; } const [ y, m, d ] = iso.split('-'); return `${d}/${m}/${y}`; } function heightOf(line: Line): number { switch (line.kind) { case 'block': // Heading plus the short dashed rule beneath it. return LINE_H + 1.8; case 'detail': case 'standalone': return LINE_H * line.lines.length; case 'gap': return 2.4; case 'grand': // Rule above, the figure, and the double rule beneath it. return LINE_H + 6; default: return LINE_H; } } /** * Flattens one side into the exact sequence of printed lines. Done up front so pagination is a * matter of measuring heights, with no recursion happening mid-page. */ function flatten(doc: jsPDF, side: BalanceSheetPdfSide): Line[] { const out: Line[] = []; // splitTextToSize measures with whatever font is currently set, so it has to match the font the // detail lines are actually drawn in — otherwise names wrap at the wrong width. doc.setFont('helvetica', 'normal').setFontSize(6.8); const pushNode = (node: BalanceSheetPdfNode, depth: number): void => { if (node.usage === 'DETAIL') { const indent = depth * 2; const wrapped: string[] = doc.splitTextToSize(node.name, NAME_W - indent); out.push({ kind: 'detail', lines: wrapped, amount: money(node.balance), depth }); return; } out.push({ kind: 'subhead', text: node.name, depth }); node.children.forEach((child) => pushNode(child, depth + 1)); out.push({ kind: 'subsubtotal', amount: money(node.balance) }); }; for (const group of side.groups) { for (const root of group.nodes) { if (root.usage === 'DETAIL') { // A Detail account sitting under no group at all: a group of one, printed on a single line // carrying both its balance and its total, rather than a heading and a figure on separate // rows with the BALANCE column left empty. out.push({ kind: 'standalone', lines: doc.splitTextToSize(root.name.toUpperCase(), NAME_W), amount: money(root.balance) }); out.push({ kind: 'gap' }); continue; } out.push({ kind: 'block', text: root.name }); root.children.forEach((child) => pushNode(child, 0)); out.push({ kind: 'subtotal', amount: money(root.balance) }); out.push({ kind: 'gap' }); } } // Carried as a line of its own so pagination reserves room for it, rather than squeezing it // against the bottom margin under the last block. out.push({ kind: 'grand', amount: money(side.total) }); return out; } function dottedLine(doc: jsPDF, x1: number, y: number, x2: number): void { doc.setLineDashPattern( [ 0.4, 0.6 ], 0 ); doc.setLineWidth(0.15); doc.line(x1, y, x2, y); doc.setLineDashPattern([], 0); } function drawPageFrame(doc: jsPDF, input: BalanceSheetPdfInput, page: number, pageCount: number): void { doc.setTextColor(...INK); doc.setFont('helvetica', 'bold').setFontSize(9.5); doc.text(`${input.bankName.toUpperCase()} , HEAD OFFICE`, PAGE_W / 2, 11, { align: 'center' }); doc.setFontSize(8); doc.text(`CONSOLIDATED BALANCE SHEET (SUB GROUP WISE) AS ON : ${ddmmyyyy(input.asOnDate)}`, PAGE_W / 2, 16, { align: 'center' }); doc.setFont('helvetica', 'normal').setFontSize(7); doc.text(`Page ${page} of ${pageCount}`, PAGE_W - MARGIN, 16, { align: 'right' }); doc.text(`FOR BRANCHES : ${input.officeLabel}`, PAGE_W / 2, 20, { align: 'center' }); dottedLine(doc, MARGIN, 23, PAGE_W - MARGIN); // Side titles and column headings, drawn per column. const titles = [ 'LIABILITIES ( CREDIT )', 'ASSETS ( DEBIT )' ]; for (let c = 0; c < 2; c++) { const x = COL_X[c]; doc.setFont('helvetica', 'bold').setFontSize(7.5); doc.text(titles[c], x + 1, 27.5); doc.setFontSize(6.5); doc.text('ACCOUNT NAME', x + 1, 32); doc.text('BALANCE', x + BALANCE_R, 32, { align: 'right' }); doc.text('TOTAL', x + TOTAL_R, 32, { align: 'right' }); dottedLine(doc, x + 1, 33.4, x + COL_W - 2); } // The divider between the two sides. doc.setLineWidth(0.15); doc.setDrawColor(150, 150, 150); doc.line(COL_X[1], 24, COL_X[1], BODY_BOTTOM + 2); doc.setDrawColor(0, 0, 0); // Footer. dottedLine(doc, MARGIN, BODY_BOTTOM + 6, PAGE_W - MARGIN); doc .setFont('helvetica', 'normal') .setFontSize(6.5) .setTextColor(...MUTED); const now = new Date(); const stamp = `${`${now.getDate()}`.padStart(2, '0')}/${`${now.getMonth() + 1}`.padStart(2, '0')}/${now.getFullYear()} ` + `${`${now.getHours()}`.padStart(2, '0')}:${`${now.getMinutes()}`.padStart(2, '0')}:${`${now.getSeconds()}`.padStart(2, '0')}`; doc.text(`Print Date : ${stamp}`, MARGIN, BODY_BOTTOM + 10); doc.text('Consolidated Balance Sheet (Sub Group Wise)', PAGE_W - MARGIN, BODY_BOTTOM + 10, { align: 'right' }); doc.setTextColor(...INK); } /** Draws one line into a column at y, and returns the y the next line starts at. */ function drawLine(doc: jsPDF, line: Line, x: number, y: number): number { switch (line.kind) { case 'block': { doc.setFont('helvetica', 'bold').setFontSize(7); doc.text(line.text.toUpperCase(), x + 1, y); dottedLine(doc, x + 1, y + 1.1, x + 34); return y + heightOf(line); } case 'subhead': { doc.setFont('helvetica', 'bold').setFontSize(6.8); doc.text(line.text, x + 1 + line.depth * 2, y); return y + heightOf(line); } case 'detail': { doc.setFont('helvetica', 'normal').setFontSize(6.8); const indent = x + 2 + line.depth * 2; line.lines.forEach((text, i) => doc.text(text, indent, y + i * LINE_H)); // The amount sits on the first line of a wrapped name, as the printed report has it. doc.text(line.amount, x + BALANCE_R, y, { align: 'right' }); return y + heightOf(line); } case 'subsubtotal': { doc.setFont('helvetica', 'bold').setFontSize(6.8); dottedLine(doc, x + BALANCE_R - 22, y - 2.2, x + BALANCE_R); doc.text(line.amount, x + BALANCE_R, y, { align: 'right' }); return y + heightOf(line); } case 'subtotal': { doc.setFont('helvetica', 'bold').setFontSize(7); doc.text(line.amount, x + TOTAL_R, y, { align: 'right' }); return y + heightOf(line); } case 'standalone': { doc.setFont('helvetica', 'bold').setFontSize(7); line.lines.forEach((text, i) => doc.text(text, x + 1, y + i * LINE_H)); doc.text(line.amount, x + BALANCE_R, y, { align: 'right' }); doc.text(line.amount, x + TOTAL_R, y, { align: 'right' }); return y + heightOf(line); } case 'grand': { const top = y + 1.5; doc.setLineWidth(0.3); doc.line(x + 1, top, x + COL_W - 2, top); doc.setFont('helvetica', 'bold').setFontSize(7.5); doc.text(line.amount, x + TOTAL_R, top + 3.6, { align: 'right' }); doc.setLineWidth(0.2); doc.line(x + 1, top + 5, x + COL_W - 2, top + 5); doc.line(x + 1, top + 5.7, x + COL_W - 2, top + 5.7); return y + heightOf(line); } default: return y + heightOf(line); } } /** Splits one side's lines into per-page chunks that fit between BODY_TOP and BODY_BOTTOM. */ function paginate(lines: Line[]): Line[][] { const pages: Line[][] = []; let current: Line[] = []; let y = BODY_TOP; for (const line of lines) { const h = heightOf(line); if (y + h > BODY_BOTTOM) { pages.push(current); current = []; y = BODY_TOP; } current.push(line); y += h; } // Keep the trailing chunk only if it has something on it, but always return at least one page so // a side with no balances still gets its heading and a zero total. if (current.length > 0 || pages.length === 0) { pages.push(current); } return pages; } export function buildBalanceSheetPdf(input: BalanceSheetPdfInput): jsPDF { const doc = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4', compress: true }); const creditPages = paginate(flatten(doc, input.creditSide)); const debitPages = paginate(flatten(doc, input.debitSide)); // Both sides print side by side, so the sheet runs as long as the longer of the two, plus a // final page for the grand totals when they would not fit under the last block. const pageCount = Math.max(creditPages.length, debitPages.length); for (let p = 0; p < pageCount; p++) { if (p > 0) { doc.addPage(); } drawPageFrame(doc, input, p + 1, pageCount); const sides = [ creditPages[p] ?? [], debitPages[p] ?? [] ]; for (let c = 0; c < 2; c++) { const x = COL_X[c]; let y = BODY_TOP; for (const line of sides[c]) { y = drawLine(doc, line, x, y); } } // Sign-off row, on the closing page only. if (p === pageCount - 1) { doc .setFont('helvetica', 'normal') .setFontSize(6.5) .setTextColor(...MUTED); const roles = [ 'CLERK', 'ACCOUNTANT / OFFICER', 'ASST. GEN MANAGER', 'GENERAL MANAGER', 'CHAIRMAN' ]; const step = (PAGE_W - MARGIN * 2) / roles.length; roles.forEach((role, i) => doc.text(role, MARGIN + step * i + step / 2, BODY_BOTTOM + 2, { align: 'center' })); doc.setTextColor(...INK); } } return doc; }