diff --git a/erpnext/accounts/report/share_balance/test_share_balance.py b/erpnext/accounts/report/share_balance/test_share_balance.py new file mode 100644 index 00000000000..c118442d239 --- /dev/null +++ b/erpnext/accounts/report/share_balance/test_share_balance.py @@ -0,0 +1,153 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe + +from erpnext.accounts.report.share_balance.share_balance import execute +from erpnext.tests.utils import ERPNextTestSuite + +COMPANY = "_Test Company" + + +class TestShareBalanceReport(ERPNextTestSuite): + def setUp(self): + self.share_type = create_share_type("_Test Share Balance Equity") + self.shareholder = create_shareholder("_Test Share Balance Holder", COMPANY) + + def test_date_filter_is_mandatory(self): + self.assertRaises(frappe.ValidationError, execute, frappe._dict({"shareholder": self.shareholder})) + + def test_no_shareholder_returns_empty_data(self): + # `shareholder` is optional; without it the report yields no rows. + columns, data = execute(frappe._dict({"date": "2026-06-01", "company": COMPANY})) + self.assertEqual(data, []) + self.assertEqual(len(columns), 5) + + def test_balance_after_issue(self): + create_share_transfer( + transfer_type="Issue", + to_shareholder=self.shareholder, + share_type=self.share_type, + from_no=1, + to_no=100, + no_of_shares=100, + rate=10, + date="2026-06-01", + ) + + row = self.get_row(date="2026-06-05") + self.assertEqual(row[0], self.shareholder) + self.assertEqual(row[1], self.share_type) + self.assertEqual(row[2], 100) # no_of_shares + self.assertEqual(row[3], 10) # average rate + self.assertEqual(row[4], 1000) # amount = 100 * 10 + + def test_balance_increases_on_second_issue(self): + create_share_transfer( + transfer_type="Issue", + to_shareholder=self.shareholder, + share_type=self.share_type, + from_no=1, + to_no=100, + no_of_shares=100, + rate=10, + date="2026-06-01", + ) + create_share_transfer( + transfer_type="Issue", + to_shareholder=self.shareholder, + share_type=self.share_type, + from_no=101, + to_no=200, + no_of_shares=100, + rate=20, + date="2026-06-10", + ) + + # The report groups by share type, summing shares and amount and + # recomputing the average rate: (1000 + 2000) / 200 = 15. + row = self.get_row(date="2026-06-15") + self.assertEqual(row[2], 200) + self.assertEqual(row[3], 15) + self.assertEqual(row[4], 3000) + + def test_balance_reduces_after_transfer_out(self): + other_holder = create_shareholder("_Test Share Balance Holder 2", COMPANY) + create_share_transfer( + transfer_type="Issue", + to_shareholder=self.shareholder, + share_type=self.share_type, + from_no=1, + to_no=100, + no_of_shares=100, + rate=10, + date="2026-06-01", + ) + create_share_transfer( + transfer_type="Transfer", + from_shareholder=self.shareholder, + to_shareholder=other_holder, + share_type=self.share_type, + from_no=1, + to_no=40, + no_of_shares=40, + rate=10, + date="2026-06-10", + ) + + row = self.get_row(date="2026-06-15") + self.assertEqual(row[2], 60) # 100 issued - 40 transferred out + self.assertEqual(row[4], 600) + + other_row = self.get_row(date="2026-06-15", shareholder=other_holder) + self.assertEqual(other_row[2], 40) + self.assertEqual(other_row[4], 400) + + def test_report_reflects_current_balance_regardless_of_date(self): + # NOTE: the report reads the Shareholder's current `share_balance` table + # and does NOT actually filter by the `date` value (it is required but + # unused in the computation). So an as-on date before the issue still + # shows the current holding rather than 0. This asserts the real + # behavior and flags the missing date-based filtering. + create_share_transfer( + transfer_type="Issue", + to_shareholder=self.shareholder, + share_type=self.share_type, + from_no=1, + to_no=100, + no_of_shares=100, + rate=10, + date="2026-06-01", + ) + + row = self.get_row(date="2020-01-01") # long before the issue + self.assertEqual(row[2], 100) + + def get_row(self, date, shareholder=None): + filters = frappe._dict( + {"date": date, "company": COMPANY, "shareholder": shareholder or self.shareholder} + ) + data = execute(filters)[1] + holdings = [r for r in data if r[1] == self.share_type] + self.assertEqual(len(holdings), 1, f"Expected one row for share type, got: {data}") + return holdings[0] + + +def create_share_type(title): + if not frappe.db.exists("Share Type", title): + frappe.get_doc({"doctype": "Share Type", "title": title}).insert() + return title + + +def create_shareholder(title, company): + shareholder = frappe.get_doc({"doctype": "Shareholder", "title": title, "company": company}).insert() + return shareholder.name + + +def create_share_transfer(**kwargs): + kwargs.setdefault("company", COMPANY) + kwargs.setdefault("asset_account", "Cash - _TC") + kwargs.setdefault("equity_or_liability_account", "Creditors - _TC") + transfer = frappe.get_doc({"doctype": "Share Transfer", **kwargs}) + transfer.submit() + return transfer