/** * 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`); } }