From 6dbaa5048e6335634faecb22a06877936ab68b77 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 25 Aug 2026 16:09:59 +0530 Subject: [PATCH 1/3] fix(accounts): prevent child table doctypes as accounting dimensions (cherry picked from commit 7b32d07d1cf2b66e71f73656773b5d4689768203) # Conflicts: # erpnext/accounts/services/base_gl_composer.py --- .../accounting_dimension.js | 2 + .../accounting_dimension.py | 8 + .../test_accounting_dimension.py | 4 + erpnext/accounts/services/base_gl_composer.py | 275 ++++++++++++++++++ 4 files changed, 289 insertions(+) create mode 100644 erpnext/accounts/services/base_gl_composer.py diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.js b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.js index 6f4f9f8d782..c4b3c87387d 100644 --- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.js +++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.js @@ -16,6 +16,8 @@ frappe.ui.form.on("Accounting Dimension", { return { filters: { name: ["not in", invalid_doctypes], + istable: 0, + issingle: 0, }, }; }); diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py index 6dac47c085b..97985dcc80c 100644 --- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py +++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py @@ -60,6 +60,14 @@ class AccountingDimension(Document): msg = _("Not allowed to create accounting dimension for {0}").format(self.document_type) frappe.throw(msg) + meta = frappe.get_meta(self.document_type) + if meta.istable or meta.issingle: + frappe.throw( + _( + "{0} cannot be used as an accounting dimension as it is not a standalone document type." + ).format(frappe.bold(self.document_type)) + ) + exists = frappe.db.get_value("Accounting Dimension", {"document_type": self.document_type}, ["name"]) if exists and self.is_new(): diff --git a/erpnext/accounts/doctype/accounting_dimension/test_accounting_dimension.py b/erpnext/accounts/doctype/accounting_dimension/test_accounting_dimension.py index 250442a3cd4..e6dbcb1c87f 100644 --- a/erpnext/accounts/doctype/accounting_dimension/test_accounting_dimension.py +++ b/erpnext/accounts/doctype/accounting_dimension/test_accounting_dimension.py @@ -52,6 +52,10 @@ class TestAccountingDimension(ERPNextTestSuite): self.assertEqual(gle.get("department"), "_Test Department - _TC") self.assertEqual(gle1.get("department"), "_Test Department - _TC") + def test_child_table_not_allowed_as_dimension(self): + dimension = frappe.get_doc({"doctype": "Accounting Dimension", "document_type": "Sales Team"}) + self.assertRaises(frappe.ValidationError, dimension.insert) + def test_mandatory(self): location = frappe.get_doc("Accounting Dimension", "Location") location.dimension_defaults[0].mandatory_for_bs = True diff --git a/erpnext/accounts/services/base_gl_composer.py b/erpnext/accounts/services/base_gl_composer.py new file mode 100644 index 00000000000..1ec53d07e44 --- /dev/null +++ b/erpnext/accounts/services/base_gl_composer.py @@ -0,0 +1,275 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Base class and free functions for per-document GL entry composition. + +``BaseGLComposer`` holds the document being composed and exposes +``get_gl_dict`` / ``add_gl_entry`` as instance methods. The underlying logic +lives in the module-level free functions below (``doc`` as first argument), so +``AccountsController`` and ``StockController`` can delegate to them via thin +shims without forcing every GL-building doctype to inherit from those classes. + +Subclasses implement ``compose`` to return the voucher-specific list of GL +entries. +""" + +import frappe +from frappe import _ +from frappe.utils import flt, formatdate + +import erpnext +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions +from erpnext.accounts.services.taxes import set_balance_in_account_currency +from erpnext.accounts.utils import get_account_currency, get_fiscal_years +from erpnext.utilities.regional import temporary_flag + + +def get_gl_dict(doc, args: dict, account_currency: str | None = None, item=None) -> dict: + """Build a GL entry dict populated with doc-level fields.""" + posting_date = args.get("posting_date") or doc.get("posting_date") + fiscal_years = get_fiscal_years(posting_date, company=doc.company) + if len(fiscal_years) > 1: + frappe.throw( + _("Multiple fiscal years exist for the date {0}. Please set company in Fiscal Year").format( + formatdate(posting_date) + ) + ) + else: + fiscal_year = fiscal_years[0][0] + + gl_dict = frappe._dict( + { + "company": doc.company, + "posting_date": posting_date, + "fiscal_year": fiscal_year, + "voucher_type": doc.doctype, + "voucher_no": doc.name, + "remarks": doc.get("remarks") or doc.get("remark"), + "debit": 0, + "credit": 0, + "debit_in_account_currency": 0, + "credit_in_account_currency": 0, + "is_opening": doc.get("is_opening") or "No", + "party_type": None, + "party": None, + "project": doc.get("project"), + "post_net_value": args.get("post_net_value"), + "voucher_detail_no": args.get("voucher_detail_no"), + "voucher_subtype": get_voucher_subtype(doc), + } + ) + + with temporary_flag("company", doc.company): + update_gl_dict_with_regional_fields(doc, gl_dict) + + update_gl_dict_with_app_based_fields(doc, gl_dict) + + accounting_dimensions = get_accounting_dimensions() + dimension_dict = frappe._dict() + for dimension in accounting_dimensions: + value = doc.get(dimension) + if item and item.get(dimension): + value = item.get(dimension) + if isinstance(value, list | dict): + continue + dimension_dict[dimension] = value + + gl_dict.update(dimension_dict) + gl_dict.update(args) + + if not account_currency: + account_currency = get_account_currency(gl_dict.account) + + if gl_dict.account and doc.doctype not in [ + "Journal Entry", + "Period Closing Voucher", + "Payment Entry", + "Purchase Receipt", + "Purchase Invoice", + "Stock Entry", + ]: + validate_account_currency(doc, gl_dict.account, account_currency) + + if gl_dict.account and doc.doctype not in [ + "Journal Entry", + "Period Closing Voucher", + "Payment Entry", + ]: + set_balance_in_account_currency( + gl_dict, + account_currency, + args.get("transaction_exchange_rate") or doc.get("conversion_rate"), + doc.company_currency, + ) + + if doc.doctype not in ["Purchase Invoice", "Sales Invoice", "Journal Entry", "Payment Entry"]: + gl_dict.update( + { + "transaction_currency": doc.get("currency") or doc.company_currency, + "transaction_exchange_rate": args.get("transaction_exchange_rate") + or doc.get("conversion_rate", 1), + "debit_in_transaction_currency": get_value_in_transaction_currency( + doc, account_currency, gl_dict, "debit" + ), + "credit_in_transaction_currency": get_value_in_transaction_currency( + doc, account_currency, gl_dict, "credit" + ), + } + ) + + if not args.get("against_voucher_type") and doc.get("against_voucher_type"): + gl_dict.update({"against_voucher_type": doc.get("against_voucher_type")}) + + if not args.get("against_voucher") and doc.get("against_voucher"): + gl_dict.update({"against_voucher": doc.get("against_voucher")}) + + return gl_dict + + +def add_gl_entry( + doc, + gl_entries: list, + account: str, + cost_center: str, + debit: float, + credit: float, + remarks: str, + against_account: str, + debit_in_account_currency: float | None = None, + credit_in_account_currency: float | None = None, + account_currency: str | None = None, + project: str | None = None, + voucher_detail_no: str | None = None, + item=None, + posting_date=None, +) -> None: + """Build a GL entry via get_gl_dict and append it to gl_entries.""" + gl_entry = { + "account": account, + "cost_center": cost_center, + "debit": debit, + "credit": credit, + "against": against_account, + "remarks": remarks, + } + + if project: + gl_entry["project"] = project + + if voucher_detail_no: + gl_entry["voucher_detail_no"] = voucher_detail_no + + if debit_in_account_currency: + gl_entry["debit_in_account_currency"] = debit_in_account_currency + + if credit_in_account_currency: + gl_entry["credit_in_account_currency"] = credit_in_account_currency + + if posting_date: + gl_entry["posting_date"] = posting_date + + gl_entries.append(get_gl_dict(doc, gl_entry, account_currency, item=item)) + + +def get_voucher_subtype(doc) -> str: + voucher_subtypes = { + "Journal Entry": "voucher_type", + "Payment Entry": "payment_type", + "Stock Entry": "stock_entry_type", + "Asset Capitalization": "entry_type", + } + + for method_name in frappe.get_hooks("voucher_subtypes"): + voucher_subtype = frappe.get_attr(method_name)(doc) + if voucher_subtype: + return voucher_subtype + + if doc.doctype in voucher_subtypes: + return doc.get(voucher_subtypes[doc.doctype]) + elif doc.doctype == "Purchase Receipt" and doc.is_return: + return "Purchase Return" + elif doc.doctype == "Delivery Note" and doc.is_return: + return "Sales Return" + elif doc.doctype == "Sales Invoice" and doc.is_return: + return "Credit Note" + elif doc.doctype == "Sales Invoice" and doc.is_debit_note: + return "Debit Note" + elif doc.doctype == "Purchase Invoice" and doc.is_return: + return "Debit Note" + + return doc.doctype + + +def get_value_in_transaction_currency(doc, account_currency: str, gl_dict: dict, field: str) -> float: + if account_currency == doc.get("currency"): + return gl_dict.get(field + "_in_account_currency") + return flt(gl_dict.get(field, 0) / doc.get("conversion_rate", 1)) + + +def validate_account_currency(doc, account: str, account_currency: str | None = None) -> None: + valid_currency = [doc.company_currency] + if doc.get("currency") and doc.currency != doc.company_currency: + valid_currency.append(doc.currency) + + if account_currency not in valid_currency: + frappe.throw( + _("Account {0} is invalid. Account Currency must be {1}").format( + account, (" " + _("or") + " ").join(valid_currency) + ) + ) + + +@erpnext.allow_regional +def update_gl_dict_with_regional_fields(doc, gl_dict): + pass + + +def update_gl_dict_with_app_based_fields(doc, gl_dict): + for method in frappe.get_hooks("update_gl_dict_with_app_based_fields", default=[]): + frappe.get_attr(method)(doc, gl_dict) + + +class BaseGLComposer: + def __init__(self, doc): + self.doc = doc + + def compose(self): + raise NotImplementedError + + def get_gl_dict(self, args: dict, account_currency: str | None = None, item=None) -> dict: + return get_gl_dict(self.doc, args, account_currency, item) + + def add_gl_entry( + self, + gl_entries: list, + account: str, + cost_center: str, + debit: float, + credit: float, + remarks: str, + against_account: str, + debit_in_account_currency: float | None = None, + credit_in_account_currency: float | None = None, + account_currency: str | None = None, + project: str | None = None, + voucher_detail_no: str | None = None, + item=None, + posting_date=None, + ) -> None: + add_gl_entry( + self.doc, + gl_entries, + account, + cost_center, + debit, + credit, + remarks, + against_account, + debit_in_account_currency, + credit_in_account_currency, + account_currency, + project, + voucher_detail_no, + item, + posting_date, + ) From fe98974cb68cab8637ec57b94fbde0ae648645c7 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 26 Aug 2026 00:36:29 +0530 Subject: [PATCH 2/3] test(accounts): cover single doctype and non-scalar dimension safeguards (cherry picked from commit 55dd11f977747470628e622752a2124658f9ec0a) --- .../test_accounting_dimension.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/erpnext/accounts/doctype/accounting_dimension/test_accounting_dimension.py b/erpnext/accounts/doctype/accounting_dimension/test_accounting_dimension.py index e6dbcb1c87f..b912dc35827 100644 --- a/erpnext/accounts/doctype/accounting_dimension/test_accounting_dimension.py +++ b/erpnext/accounts/doctype/accounting_dimension/test_accounting_dimension.py @@ -56,6 +56,19 @@ class TestAccountingDimension(ERPNextTestSuite): dimension = frappe.get_doc({"doctype": "Accounting Dimension", "document_type": "Sales Team"}) self.assertRaises(frappe.ValidationError, dimension.insert) + def test_single_doctype_not_allowed_as_dimension(self): + dimension = frappe.get_doc({"doctype": "Accounting Dimension", "document_type": "Selling Settings"}) + self.assertRaises(frappe.ValidationError, dimension.insert) + + def test_non_scalar_dimension_value_skipped_in_gl_dict(self): + si = create_sales_invoice(do_not_save=1) + + si.department = "_Test Department - _TC" + self.assertEqual(si.get_gl_dict({}).get("department"), "_Test Department - _TC") + + si.department = ["_Test Department - _TC"] + self.assertNotIn("department", si.get_gl_dict({})) + def test_mandatory(self): location = frappe.get_doc("Accounting Dimension", "Location") location.dimension_defaults[0].mandatory_for_bs = True From abf311e8ca20e828f115cb61487cac07fc69c06f Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 26 Aug 2026 11:05:19 +0530 Subject: [PATCH 3/3] fix(accounts): guard get_gl_dict for v16 and drop unused base_gl_composer --- erpnext/accounts/services/base_gl_composer.py | 275 ------------------ erpnext/controllers/accounts_controller.py | 7 +- 2 files changed, 5 insertions(+), 277 deletions(-) delete mode 100644 erpnext/accounts/services/base_gl_composer.py diff --git a/erpnext/accounts/services/base_gl_composer.py b/erpnext/accounts/services/base_gl_composer.py deleted file mode 100644 index 1ec53d07e44..00000000000 --- a/erpnext/accounts/services/base_gl_composer.py +++ /dev/null @@ -1,275 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -"""Base class and free functions for per-document GL entry composition. - -``BaseGLComposer`` holds the document being composed and exposes -``get_gl_dict`` / ``add_gl_entry`` as instance methods. The underlying logic -lives in the module-level free functions below (``doc`` as first argument), so -``AccountsController`` and ``StockController`` can delegate to them via thin -shims without forcing every GL-building doctype to inherit from those classes. - -Subclasses implement ``compose`` to return the voucher-specific list of GL -entries. -""" - -import frappe -from frappe import _ -from frappe.utils import flt, formatdate - -import erpnext -from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions -from erpnext.accounts.services.taxes import set_balance_in_account_currency -from erpnext.accounts.utils import get_account_currency, get_fiscal_years -from erpnext.utilities.regional import temporary_flag - - -def get_gl_dict(doc, args: dict, account_currency: str | None = None, item=None) -> dict: - """Build a GL entry dict populated with doc-level fields.""" - posting_date = args.get("posting_date") or doc.get("posting_date") - fiscal_years = get_fiscal_years(posting_date, company=doc.company) - if len(fiscal_years) > 1: - frappe.throw( - _("Multiple fiscal years exist for the date {0}. Please set company in Fiscal Year").format( - formatdate(posting_date) - ) - ) - else: - fiscal_year = fiscal_years[0][0] - - gl_dict = frappe._dict( - { - "company": doc.company, - "posting_date": posting_date, - "fiscal_year": fiscal_year, - "voucher_type": doc.doctype, - "voucher_no": doc.name, - "remarks": doc.get("remarks") or doc.get("remark"), - "debit": 0, - "credit": 0, - "debit_in_account_currency": 0, - "credit_in_account_currency": 0, - "is_opening": doc.get("is_opening") or "No", - "party_type": None, - "party": None, - "project": doc.get("project"), - "post_net_value": args.get("post_net_value"), - "voucher_detail_no": args.get("voucher_detail_no"), - "voucher_subtype": get_voucher_subtype(doc), - } - ) - - with temporary_flag("company", doc.company): - update_gl_dict_with_regional_fields(doc, gl_dict) - - update_gl_dict_with_app_based_fields(doc, gl_dict) - - accounting_dimensions = get_accounting_dimensions() - dimension_dict = frappe._dict() - for dimension in accounting_dimensions: - value = doc.get(dimension) - if item and item.get(dimension): - value = item.get(dimension) - if isinstance(value, list | dict): - continue - dimension_dict[dimension] = value - - gl_dict.update(dimension_dict) - gl_dict.update(args) - - if not account_currency: - account_currency = get_account_currency(gl_dict.account) - - if gl_dict.account and doc.doctype not in [ - "Journal Entry", - "Period Closing Voucher", - "Payment Entry", - "Purchase Receipt", - "Purchase Invoice", - "Stock Entry", - ]: - validate_account_currency(doc, gl_dict.account, account_currency) - - if gl_dict.account and doc.doctype not in [ - "Journal Entry", - "Period Closing Voucher", - "Payment Entry", - ]: - set_balance_in_account_currency( - gl_dict, - account_currency, - args.get("transaction_exchange_rate") or doc.get("conversion_rate"), - doc.company_currency, - ) - - if doc.doctype not in ["Purchase Invoice", "Sales Invoice", "Journal Entry", "Payment Entry"]: - gl_dict.update( - { - "transaction_currency": doc.get("currency") or doc.company_currency, - "transaction_exchange_rate": args.get("transaction_exchange_rate") - or doc.get("conversion_rate", 1), - "debit_in_transaction_currency": get_value_in_transaction_currency( - doc, account_currency, gl_dict, "debit" - ), - "credit_in_transaction_currency": get_value_in_transaction_currency( - doc, account_currency, gl_dict, "credit" - ), - } - ) - - if not args.get("against_voucher_type") and doc.get("against_voucher_type"): - gl_dict.update({"against_voucher_type": doc.get("against_voucher_type")}) - - if not args.get("against_voucher") and doc.get("against_voucher"): - gl_dict.update({"against_voucher": doc.get("against_voucher")}) - - return gl_dict - - -def add_gl_entry( - doc, - gl_entries: list, - account: str, - cost_center: str, - debit: float, - credit: float, - remarks: str, - against_account: str, - debit_in_account_currency: float | None = None, - credit_in_account_currency: float | None = None, - account_currency: str | None = None, - project: str | None = None, - voucher_detail_no: str | None = None, - item=None, - posting_date=None, -) -> None: - """Build a GL entry via get_gl_dict and append it to gl_entries.""" - gl_entry = { - "account": account, - "cost_center": cost_center, - "debit": debit, - "credit": credit, - "against": against_account, - "remarks": remarks, - } - - if project: - gl_entry["project"] = project - - if voucher_detail_no: - gl_entry["voucher_detail_no"] = voucher_detail_no - - if debit_in_account_currency: - gl_entry["debit_in_account_currency"] = debit_in_account_currency - - if credit_in_account_currency: - gl_entry["credit_in_account_currency"] = credit_in_account_currency - - if posting_date: - gl_entry["posting_date"] = posting_date - - gl_entries.append(get_gl_dict(doc, gl_entry, account_currency, item=item)) - - -def get_voucher_subtype(doc) -> str: - voucher_subtypes = { - "Journal Entry": "voucher_type", - "Payment Entry": "payment_type", - "Stock Entry": "stock_entry_type", - "Asset Capitalization": "entry_type", - } - - for method_name in frappe.get_hooks("voucher_subtypes"): - voucher_subtype = frappe.get_attr(method_name)(doc) - if voucher_subtype: - return voucher_subtype - - if doc.doctype in voucher_subtypes: - return doc.get(voucher_subtypes[doc.doctype]) - elif doc.doctype == "Purchase Receipt" and doc.is_return: - return "Purchase Return" - elif doc.doctype == "Delivery Note" and doc.is_return: - return "Sales Return" - elif doc.doctype == "Sales Invoice" and doc.is_return: - return "Credit Note" - elif doc.doctype == "Sales Invoice" and doc.is_debit_note: - return "Debit Note" - elif doc.doctype == "Purchase Invoice" and doc.is_return: - return "Debit Note" - - return doc.doctype - - -def get_value_in_transaction_currency(doc, account_currency: str, gl_dict: dict, field: str) -> float: - if account_currency == doc.get("currency"): - return gl_dict.get(field + "_in_account_currency") - return flt(gl_dict.get(field, 0) / doc.get("conversion_rate", 1)) - - -def validate_account_currency(doc, account: str, account_currency: str | None = None) -> None: - valid_currency = [doc.company_currency] - if doc.get("currency") and doc.currency != doc.company_currency: - valid_currency.append(doc.currency) - - if account_currency not in valid_currency: - frappe.throw( - _("Account {0} is invalid. Account Currency must be {1}").format( - account, (" " + _("or") + " ").join(valid_currency) - ) - ) - - -@erpnext.allow_regional -def update_gl_dict_with_regional_fields(doc, gl_dict): - pass - - -def update_gl_dict_with_app_based_fields(doc, gl_dict): - for method in frappe.get_hooks("update_gl_dict_with_app_based_fields", default=[]): - frappe.get_attr(method)(doc, gl_dict) - - -class BaseGLComposer: - def __init__(self, doc): - self.doc = doc - - def compose(self): - raise NotImplementedError - - def get_gl_dict(self, args: dict, account_currency: str | None = None, item=None) -> dict: - return get_gl_dict(self.doc, args, account_currency, item) - - def add_gl_entry( - self, - gl_entries: list, - account: str, - cost_center: str, - debit: float, - credit: float, - remarks: str, - against_account: str, - debit_in_account_currency: float | None = None, - credit_in_account_currency: float | None = None, - account_currency: str | None = None, - project: str | None = None, - voucher_detail_no: str | None = None, - item=None, - posting_date=None, - ) -> None: - add_gl_entry( - self.doc, - gl_entries, - account, - cost_center, - debit, - credit, - remarks, - against_account, - debit_in_account_currency, - credit_in_account_currency, - account_currency, - project, - voucher_detail_no, - item, - posting_date, - ) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index a13417065e5..9ce120b2c7f 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1421,9 +1421,12 @@ class AccountsController(TransactionBase): dimension_dict = frappe._dict() for dimension in accounting_dimensions: - dimension_dict[dimension] = self.get(dimension) + value = self.get(dimension) if item and item.get(dimension): - dimension_dict[dimension] = item.get(dimension) + value = item.get(dimension) + if isinstance(value, list | dict): + continue + dimension_dict[dimension] = value gl_dict.update(dimension_dict) gl_dict.update(args)