fix: tenant-config fixes, cashier/share/savings UI bugs, and menu-access hardcoding

- 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>
This commit is contained in:
2026-09-13 15:19:21 +05:30
co-authored by Claude Sonnet 5
parent 42424dff8b
commit 7b77166b19
39 changed files with 1800 additions and 266 deletions
@@ -0,0 +1,363 @@
/**
* 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;
}
@@ -0,0 +1,182 @@
<!--
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/.
-->
<div class="bs-page">
<!-- Controls — not part of the printed sheet. -->
<div class="bs-controls">
<label class="bs-field">
<span>As on date</span>
<input type="date" [(ngModel)]="asOnDate" (change)="load()" />
</label>
<label class="bs-field">
<span>Branch</span>
<select [(ngModel)]="officeId" (ngModelChange)="load()">
<option [ngValue]="null">All branches</option>
@for (office of offices; track office.id) {
<option [ngValue]="office.id">{{ office.name }}</option>
}
</select>
</label>
<label class="bs-check">
<input type="checkbox" [(ngModel)]="hideZeroBalances" (ngModelChange)="applyZeroFilter()" />
<span>Hide nil balances</span>
</label>
<span class="bs-controls__spacer"></span>
@if (!loading && debitSide && creditSide) {
<span class="bs-tally" [class.bs-tally--off]="!tallied">
{{ tallied ? 'Balanced' : 'Out of balance by ' + fmt(difference) }}
</span>
}
<button type="button" class="bs-btn" (click)="load()" [disabled]="loading">
{{ loading ? 'Loading…' : 'Refresh' }}
</button>
<button type="button" class="bs-btn bs-btn--primary" (click)="downloadPdf()" [disabled]="loading || !debitSide">
Download PDF
</button>
</div>
@if (error) {
<div class="bs-alert">{{ error }}</div>
}
@if (loading) {
<div class="bs-loading">Loading balance sheet…</div>
}
@if (!loading && debitSide && creditSide) {
<!-- The sheet itself — this block is what prints. -->
<div class="bs-sheet">
<header class="bs-sheet__head">
<h2>CONSOLIDATED BALANCE SHEET (SUB GROUP WISE)</h2>
<p>AS ON : {{ displayDate(asOnDateLoaded) }}</p>
<p class="bs-sheet__branches">FOR BRANCHES : {{ officeLabel }}</p>
</header>
<div class="bs-sheet__body">
<!-- Liabilities (credit) on the left, assets (debit) on the right — as the printed sheet reads. -->
<section class="bs-col">
<div class="bs-col__title">LIABILITIES ( CREDIT )</div>
<div class="bs-row bs-row--colhead">
<span>ACCOUNT NAME</span>
<span>BALANCE</span>
<span>TOTAL</span>
</div>
@for (group of creditSide.groups; track group.classification) {
@for (root of group.nodes; track root.id) {
<ng-container *ngTemplateOutlet="block; context: { $implicit: root }"></ng-container>
}
}
@if (creditSide.groups.length === 0) {
<div class="bs-empty">No liability, equity or income balances as on this date.</div>
}
<div class="bs-row bs-row--grand">
<span></span>
<span></span>
<span>{{ fmt(creditSide.total) }}</span>
</div>
</section>
<section class="bs-col">
<div class="bs-col__title">ASSETS ( DEBIT )</div>
<div class="bs-row bs-row--colhead">
<span>ACCOUNT NAME</span>
<span>BALANCE</span>
<span>TOTAL</span>
</div>
@for (group of debitSide.groups; track group.classification) {
@for (root of group.nodes; track root.id) {
<ng-container *ngTemplateOutlet="block; context: { $implicit: root }"></ng-container>
}
}
@if (debitSide.groups.length === 0) {
<div class="bs-empty">No asset or expense balances as on this date.</div>
}
<div class="bs-row bs-row--grand">
<span></span>
<span></span>
<span>{{ fmt(debitSide.total) }}</span>
</div>
</section>
</div>
<footer class="bs-sheet__foot">
<span>CLERK</span>
<span>ACCOUNTANT / OFFICER</span>
<span>ASST. GEN MANAGER</span>
<span>GENERAL MANAGER</span>
<span>CHAIRMAN</span>
</footer>
</div>
}
</div>
<!--
One top-level block: the Header account's name, a rule, its lines, then its subtotal in the TOTAL
column.
-->
<ng-template #block let-root>
@if (root.usage === 'DETAIL') {
<!-- A Detail account with no Header of its own: a group of one, so it gets a single line
carrying both its balance and its total rather than a heading repeating its own name. -->
<div class="bs-block bs-block--single">
<div class="bs-row bs-row--standalone">
<span class="bs-name" [title]="root.glCode">{{ root.name }}</span>
<span class="bs-amt">{{ fmt(root.balance) }}</span>
<span class="bs-amt">{{ fmt(root.balance) }}</span>
</div>
</div>
} @else {
<div class="bs-block">
<div class="bs-block__name" [title]="root.glCode">{{ root.name }}</div>
<div class="bs-block__rule"></div>
@for (child of root.children; track child.id) {
<ng-container *ngTemplateOutlet="lines; context: { $implicit: child, depth: 0 }"></ng-container>
}
@if (root.children.length === 0) {
<div class="bs-row bs-row--line">
<span class="bs-name bs-name--muted">No accounts under this group</span>
<span></span>
<span></span>
</div>
}
<div class="bs-row bs-row--subtotal">
<span></span>
<span></span>
<span>{{ fmt(root.balance) }}</span>
</div>
</div>
}
</ng-template>
<!--
Everything below a top-level block. A nested Header keeps its own name and its own subtotal, so a
Header inside a Header still reads as its own sub-group rather than flattening into the parent.
-->
<ng-template #lines let-node let-depth="depth">
@if (node.usage === 'DETAIL') {
<div class="bs-row bs-row--line">
<span class="bs-name" [style.paddingLeft.px]="depth * 14" [title]="node.glCode">{{ node.name }}</span>
<span class="bs-amt">{{ fmt(node.balance) }}</span>
<span></span>
</div>
} @else {
<div class="bs-row bs-row--subhead">
<span class="bs-name" [style.paddingLeft.px]="depth * 14" [title]="node.glCode">{{ node.name }}</span>
<span></span>
<span></span>
</div>
@for (child of node.children; track child.id) {
<ng-container *ngTemplateOutlet="lines; context: { $implicit: child, depth: depth + 1 }"></ng-container>
}
<div class="bs-row bs-row--subsubtotal">
<span></span>
<span class="bs-amt">{{ fmt(node.balance) }}</span>
<span></span>
</div>
}
</ng-template>
@@ -0,0 +1,372 @@
/**
* 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/.
*/
$ink: #111827;
$ink-soft: #374151;
$ink-faint: #9ca3af;
$rule: #cbd5e1;
$border: #e2e8f0;
$accent: #1d4ed8;
$ok: #15803d;
$ok-soft: #f0fdf4;
$danger: #b91c1c;
$danger-soft: #fef2f2;
// Amount columns share one width so BALANCE and TOTAL line up down the whole sheet.
$amt-col: 128px;
.bs-page {
padding: 20px 24px 40px;
background: #f1f5f9;
min-height: 100%;
}
// ── Controls (screen only) ────────────────────────────────────────────────
.bs-controls {
display: flex;
align-items: flex-end;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 18px;
&__spacer {
flex: 1 1 auto;
}
}
.bs-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: $ink-soft;
input,
select {
height: 36px;
padding: 0 10px;
border: 1px solid $border;
border-radius: 6px;
background: #fff;
font-size: 13px;
font-weight: 500;
text-transform: none;
letter-spacing: normal;
color: $ink;
}
}
.bs-check {
display: flex;
align-items: center;
gap: 6px;
height: 36px;
font-size: 12.5px;
font-weight: 600;
color: $ink-soft;
cursor: pointer;
user-select: none;
input {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: $accent;
}
}
.bs-btn {
height: 36px;
padding: 0 16px;
border: 1px solid $border;
border-radius: 6px;
background: #fff;
color: $ink-soft;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
&--primary {
background: $ink;
border-color: $ink;
color: #fff;
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.bs-tally {
padding: 6px 12px;
border-radius: 999px;
background: $ok-soft;
color: $ok;
font-size: 12px;
font-weight: 700;
&--off {
background: $danger-soft;
color: $danger;
}
}
.bs-alert {
padding: 10px 14px;
border-radius: 6px;
background: $danger-soft;
color: $danger;
border: 1px solid #fecaca;
font-size: 13px;
margin-bottom: 14px;
}
.bs-loading {
padding: 48px 0;
text-align: center;
color: $ink-faint;
font-size: 13.5px;
}
// ── The printed sheet ─────────────────────────────────────────────────────
.bs-sheet {
background: #fff;
border: 1px solid $border;
padding: 22px 26px 18px;
// The ledger reads as a printed report, so the whole sheet is set in a mono face with
// figures on tabular numerals — columns stay aligned however long the names run.
font-family: 'DejaVu Sans Mono', 'Liberation Mono', Consolas, 'Courier New', monospace;
font-variant-numeric: tabular-nums;
color: $ink;
&__head {
text-align: center;
border-bottom: 1px dotted $rule;
padding-bottom: 10px;
margin-bottom: 12px;
h2 {
margin: 0;
font-size: 13.5px;
font-weight: 700;
letter-spacing: 0.04em;
}
p {
margin: 4px 0 0;
font-size: 11.5px;
color: $ink-soft;
}
}
&__branches {
letter-spacing: 0.05em;
}
&__body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
}
&__foot {
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 26px;
padding-top: 10px;
border-top: 1px dotted $rule;
font-size: 10px;
letter-spacing: 0.05em;
color: $ink-soft;
}
}
.bs-col {
padding: 0 16px;
min-width: 0;
&:first-child {
border-right: 1px dotted $rule;
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
&__title {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.05em;
padding-bottom: 6px;
}
}
// ── Rows: one grid so name / BALANCE / TOTAL align across every block ──────
.bs-row {
display: grid;
grid-template-columns: minmax(0, 1fr) $amt-col $amt-col;
gap: 8px;
align-items: baseline;
font-size: 11.5px;
line-height: 1.55;
> span:nth-child(2),
> span:nth-child(3) {
text-align: right;
white-space: nowrap;
}
&--colhead {
font-weight: 700;
font-size: 10.5px;
letter-spacing: 0.05em;
border-bottom: 1px dotted $rule;
padding-bottom: 4px;
margin-bottom: 6px;
color: $ink-soft;
}
&--line {
color: $ink-soft;
}
// A nested Header inside a block: its own named sub-group.
&--subhead {
font-weight: 700;
color: $ink;
padding-top: 3px;
}
// Sub-group subtotal sits in the BALANCE column, so it never collides with the
// block's own figure in TOTAL.
&--subsubtotal {
border-top: 1px dotted $rule;
font-weight: 700;
color: $accent;
}
// Top-level block subtotal, in the TOTAL column.
&--subtotal {
font-weight: 700;
color: $ink;
padding-top: 2px;
}
// A Header-less Detail account: its own one-line group.
&--standalone {
font-weight: 700;
color: $ink;
.bs-name {
text-transform: uppercase;
letter-spacing: 0.03em;
}
}
&--grand {
margin-top: 14px;
padding-top: 8px;
border-top: 1px solid $ink;
border-bottom: 3px double $ink;
font-weight: 700;
font-size: 12.5px;
}
}
.bs-name {
overflow-wrap: anywhere;
&--muted {
color: $ink-faint;
font-style: italic;
}
}
.bs-amt {
font-variant-numeric: tabular-nums;
}
.bs-block {
margin-bottom: 14px;
&--single {
margin-bottom: 10px;
}
&__name {
font-size: 11.5px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
}
// The short dashed rule the printed sheet puts under every group heading.
&__rule {
width: 130px;
border-top: 1px dotted $ink-faint;
margin: 2px 0 4px;
}
}
.bs-empty {
padding: 14px 0;
font-size: 11.5px;
color: $ink-faint;
}
// ── Narrow screens: stack the two sides instead of squeezing the columns ───
@media (width <= 1100px) {
.bs-sheet__body {
grid-template-columns: 1fr;
}
.bs-col {
padding: 0;
&:first-child {
border-right: none;
border-bottom: 1px dotted $rule;
padding-bottom: 16px;
margin-bottom: 16px;
}
}
}
// ── Print: only the sheet, at report proportions ───────────────────────────
@media print {
.bs-controls,
.bs-alert,
.bs-loading {
display: none !important;
}
.bs-page {
padding: 0;
background: #fff;
}
.bs-sheet {
border: none;
padding: 0;
font-size: 10px;
}
.bs-sheet__body {
grid-template-columns: 1fr 1fr;
}
.bs-block {
break-inside: avoid;
}
}
@@ -0,0 +1,218 @@
/**
* 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/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AccountingService } from '../accounting.service';
import { OrganizationService } from '../../organization/organization.service';
import { buildBalanceSheetPdf } from './balance-sheet-pdf';
interface BalanceSheetNode {
id: number;
name: string;
glCode: string;
usage: 'HEADER' | 'DETAIL';
balance: number;
children: BalanceSheetNode[];
}
interface BalanceSheetGroup {
classification: string;
total: number;
nodes: BalanceSheetNode[];
}
interface BalanceSheetSide {
label: string;
total: number;
groups: BalanceSheetGroup[];
}
/**
* Consolidated Balance Sheet (sub-group wise), laid out the way the bank's own printed balance
* sheet reads: liabilities on the left, assets on the right, each top-level Header account a named
* block whose Detail accounts are listed under it and subtotalled in the TOTAL column.
*
* Balances come from BalanceSheetApiResource, which reads the live ledger - a Detail account's own
* postings, and a Header's as the recursive sum of its children (a Header never carries a posting
* of its own). A Detail account that sits under no Header at all is shown as its own one-line block
* rather than being dropped.
*/
@Component({
selector: 'mifosx-balance-sheet',
standalone: true,
imports: [
CommonModule,
FormsModule
],
templateUrl: './balance-sheet.component.html',
styleUrl: './balance-sheet.component.scss'
})
export class BalanceSheetComponent implements OnInit {
private accountingService = inject(AccountingService);
private organizationService = inject(OrganizationService);
/** Printed at the head of the PDF. Falls back to a neutral title if the profile is not set up. */
bankName = 'Bank';
asOnDate: string = new Date().toISOString().slice(0, 10);
officeId: number | null = null;
offices: { id: number; name: string }[] = [];
loading = false;
error = '';
asOnDateLoaded = '';
/** What the server returned, kept whole so the zero-balance toggle never needs a refetch. */
private rawDebitSide: BalanceSheetSide | null = null;
private rawCreditSide: BalanceSheetSide | null = null;
debitSide: BalanceSheetSide | null = null;
creditSide: BalanceSheetSide | null = null;
/**
* A working chart of accounts carries far more accounts than a bank actually operates, and a
* printed balance sheet lists only the ones carrying a balance — leaving every nil line in makes
* the sheet unreadable. On by default for that reason; switch it off to audit the full chart.
*/
hideZeroBalances = true;
/** Both sides are derived from the same double-entry ledger, so they can only disagree if a
* posting is missing a leg — worth surfacing rather than hiding. */
get tallied(): boolean {
if (!this.debitSide || !this.creditSide) {
return true;
}
return Math.abs(this.debitSide.total - this.creditSide.total) < 0.01;
}
get difference(): number {
if (!this.debitSide || !this.creditSide) {
return 0;
}
return Math.abs(this.debitSide.total - this.creditSide.total);
}
get officeLabel(): string {
if (this.officeId == null) {
return 'ALL BRANCHES';
}
return (this.offices.find((o) => o.id === this.officeId)?.name ?? '').toUpperCase();
}
ngOnInit(): void {
this.accountingService.getOffices().subscribe({
next: (offices) => (this.offices = offices || []),
error: () => (this.offices = [])
});
this.organizationService.getInstitutionProfile().subscribe({
next: (profile: any) => {
this.bankName = profile?.statementHeaderName || profile?.bankName || this.bankName;
},
// A missing institution profile must not stop the sheet from loading — the PDF just carries
// the fallback title.
error: () => undefined
});
this.load();
}
load(): void {
this.loading = true;
this.error = '';
this.accountingService.getBalanceSheet(this.asOnDate, this.officeId ?? undefined).subscribe({
next: (res: any) => {
this.rawDebitSide = res.debitSide;
this.rawCreditSide = res.creditSide;
this.asOnDateLoaded = res.asOnDate;
this.applyZeroFilter();
this.loading = false;
},
error: () => {
this.error = 'Could not load the balance sheet for this date.';
this.loading = false;
}
});
}
/**
* Rebuilds the displayed sides from what the server sent, dropping nil lines when asked. The
* totals are deliberately left as the server computed them — hiding a nil line must never change
* a figure, and a group total is unaffected by removing children that summed to nothing.
*/
applyZeroFilter(): void {
this.debitSide = this.filterSide(this.rawDebitSide);
this.creditSide = this.filterSide(this.rawCreditSide);
}
private filterSide(side: BalanceSheetSide | null): BalanceSheetSide | null {
if (!side) {
return null;
}
if (!this.hideZeroBalances) {
return side;
}
const groups = side.groups
.map((group) => ({
...group,
nodes: group.nodes
.map((node) => this.pruneZeros(node))
.filter((node): node is BalanceSheetNode => node !== null)
}))
.filter((group) => group.nodes.length > 0);
return { ...side, groups };
}
/** Drops a node whose balance is nil, and any Header left with nothing under it. */
private pruneZeros(node: BalanceSheetNode): BalanceSheetNode | null {
if (Math.abs(node.balance) < 0.005) {
return null;
}
const children = node.children
.map((child) => this.pruneZeros(child))
.filter((child): child is BalanceSheetNode => child !== null);
return { ...node, children };
}
/** Indian grouping with two decimals, matching the printed report (2,76,92,608.00). */
fmt(value: number): string {
return new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value ?? 0);
}
/** dd/MM/yyyy, as the printed report dates it. */
displayDate(iso: string): string {
if (!iso) {
return '';
}
const [
y,
m,
d
] = iso.split('-');
return `${d}/${m}/${y}`;
}
/**
* Builds the report as a real PDF rather than printing the page: window.print() takes the whole
* app shell with it and breaks pages wherever they happen to fall, which is what made the earlier
* output unusable.
*/
downloadPdf(): void {
if (!this.debitSide || !this.creditSide) {
return;
}
const doc = buildBalanceSheetPdf({
bankName: this.bankName,
officeLabel: this.officeLabel,
asOnDate: this.asOnDateLoaded || this.asOnDate,
creditSide: this.creditSide,
debitSide: this.debitSide
});
doc.save(`balance-sheet-${this.asOnDateLoaded || this.asOnDate}.pdf`);
}
}