- 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>
219 lines
7.1 KiB
TypeScript
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`);
|
|
}
|
|
}
|