Files
cbsnexora/src/app/accounting/balance-sheet/balance-sheet.component.ts
T
niteshandClaude Sonnet 5 7b77166b19 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>
2026-09-13 15:19:21 +05:30

219 lines
7.1 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/.
*/
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`);
}
}