- Add Balance Sheet report and disabled-features accounting config - Point menu-access tenant header and env.js/environment.ts tenant identifier at env config instead of hardcoded 'default'/'lincpay' literals - Surface the real backend error (not a generic "no reference id") when an OTP send fails in contact-otp-block - Fix branch-day "₹null" display bug (null vs undefined) and add a guard blocking a branch's first-ever day open before cash denomination migration - Dynamically size the denomination count input and block re-opening an already-open business day from Migrate Cash Denomination - Lock the denomination-managed cash GL row in Migrate Opening Balances (read-only, with a link back to Migrate Cash Denomination) - Remove duplicate Cancel/Submit action bars on the Share Product and Savings Account creation preview steps; align Previous into the same row - Hide "Apply Collection" on a share account once its advance collection is already settled (further purchases go through Apply Additional Shares) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
364 lines
12 KiB
TypeScript
364 lines
12 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/.
|
|
*/
|
|
|
|
/*
|
|
* 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;
|
|
}
|