From fd01171df5742e8357aae494e9177a59c99179f7 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:34:30 +0530 Subject: [PATCH] fix: added missing validations for `Dunning Type` (backport #57224) (#57226) Co-authored-by: diptanilsaha --- .../doctype/dunning_type/dunning_type.py | 134 ++++++++++++ .../doctype/dunning_type/test_dunning_type.py | 198 +++++++++++++++++- 2 files changed, 328 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/dunning_type/dunning_type.py b/erpnext/accounts/doctype/dunning_type/dunning_type.py index 77f2e004e3d..f267ee5b9a1 100644 --- a/erpnext/accounts/doctype/dunning_type/dunning_type.py +++ b/erpnext/accounts/doctype/dunning_type/dunning_type.py @@ -3,7 +3,10 @@ import frappe +from frappe import _ from frappe.model.document import Document +from frappe.utils import comma_and +from frappe.utils.jinja import validate_template class DunningType(Document): @@ -30,3 +33,134 @@ class DunningType(Document): def autoname(self): company_abbr = frappe.get_value("Company", self.company, "abbr") self.name = f"{self.dunning_type} - {company_abbr}" + + def validate(self): + self.validate_dunning_letter_text() + self.validate_income_account() + self.validate_cost_center() + self.set_default_dunning_type() + + def validate_dunning_letter_text(self): + self.validate_languages() + self.validate_is_default_language() + self.validate_dunning_letter_text_templates() + + def validate_income_account(self): + if not self.income_account: + return + + account = frappe.get_cached_doc("Account", self.income_account) + + msg = [] + if account.company != self.company: + msg.append( + _( + "{0} doesn't belong to Company {1}. Please select an Income Account that belongs to Company {1}." + ).format(frappe.bold(self.income_account), frappe.bold(self.company)) + ) + + if account.disabled: + msg.append( + _("{0} is disabled. Please select a valid Income Account.").format( + frappe.bold(self.income_account) + ) + ) + + if account.root_type != "Income": + msg.append( + _("{0} is not an Income Account. Please select a valid Income Account.").format( + frappe.bold(self.income_account) + ) + ) + + if account.is_group: + msg.append( + _("{0} is a group account. Please select a non-group Income Account.").format( + frappe.bold(self.income_account) + ) + ) + + if msg: + frappe.msgprint( + msg, + title=_("Income Account Validation Error"), + as_list=True, + raise_exception=frappe.ValidationError, + ) + + def validate_cost_center(self): + if not self.cost_center: + return + + cost_center = frappe.get_cached_doc("Cost Center", self.cost_center) + + msg = [] + if cost_center.company != self.company: + msg.append( + _( + "{0} doesn't belong to Company {1}. Please select a Cost Center that belongs to Company {1}." + ).format(frappe.bold(self.cost_center), frappe.bold(self.company)) + ) + + if cost_center.disabled: + msg.append( + _("{0} is disabled. Please select an enabled Cost Center.").format( + frappe.bold(self.cost_center) + ) + ) + + if cost_center.is_group: + msg.append( + _("{0} is a group Cost Center. Please select a non-group Cost Center.").format( + frappe.bold(self.cost_center) + ) + ) + + if msg: + frappe.msgprint( + msg, + title=_("Cost Center Validation Error"), + as_list=True, + raise_exception=frappe.ValidationError, + ) + + def validate_languages(self): + languages = [d.language for d in self.dunning_letter_text] + + if len(languages) == len(set(languages)): + return + + frappe.throw(_("Duplicate languages found on Dunning Letter Text. Keep only one of them.")) + + def validate_is_default_language(self): + is_default_language_list = [ + d.language for d in self.dunning_letter_text if d.is_default_language == 1 + ] + + if len(is_default_language_list) <= 1: + return + + frappe.throw( + _("{0} languages are marked as default languages. Please select only one of them.").format( + comma_and(is_default_language_list, add_quotes=True) + ) + ) + + def validate_dunning_letter_text_templates(self): + for d in self.dunning_letter_text: + if d.body_text: + validate_template(d.body_text, restrict_globals=True) + + if d.closing_text: + validate_template(d.closing_text, restrict_globals=True) + + def set_default_dunning_type(self): + if self.is_default != 1: + return + + frappe.db.set_value( + "Dunning Type", + {"company": self.company, "is_default": 1, "name": ["!=", self.name]}, + "is_default", + 0, + ) diff --git a/erpnext/accounts/doctype/dunning_type/test_dunning_type.py b/erpnext/accounts/doctype/dunning_type/test_dunning_type.py index 67b72e4be75..bdec57ec142 100644 --- a/erpnext/accounts/doctype/dunning_type/test_dunning_type.py +++ b/erpnext/accounts/doctype/dunning_type/test_dunning_type.py @@ -1,9 +1,199 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -# import frappe -import unittest +import frappe +from frappe.tests.utils import FrappeTestCase -class TestDunningType(unittest.TestCase): - pass +def make_dunning_type(dunning_type, company="_Test Company", **kwargs): + doc = frappe.new_doc("Dunning Type") + doc.dunning_type = dunning_type + doc.company = company + doc.dunning_fee = kwargs.get("dunning_fee", 100) + doc.rate_of_interest = kwargs.get("rate_of_interest", 5) + doc.is_default = kwargs.get("is_default", 0) + + if "income_account" in kwargs: + doc.income_account = kwargs["income_account"] + elif kwargs.get("income_account") is not False: + doc.income_account = "Sales - _TC" if company == "_Test Company" else "Sales - _TC1" + + if "cost_center" in kwargs: + doc.cost_center = kwargs["cost_center"] + elif kwargs.get("cost_center") is not False: + doc.cost_center = "Main - _TC" if company == "_Test Company" else "Main - _TC1" + + for row in kwargs.get("dunning_letter_text", [{"language": "en", "body_text": "Test body"}]): + doc.append("dunning_letter_text", row) + + return doc + + +class TestDunningType(FrappeTestCase): + def test_income_account_must_belong_to_company(self): + doc = make_dunning_type("_Test Dunning Wrong Company Account", income_account="Sales - _TC1") + self.assertRaisesRegex(frappe.ValidationError, "doesn't belong to Company", doc.insert) + + def test_income_account_must_not_be_disabled(self): + disabled_account = frappe.get_doc( + { + "doctype": "Account", + "account_name": "_Test Disabled Income Account", + "parent_account": "Direct Income - _TC", + "company": "_Test Company", + "account_type": "Income Account", + "disabled": 1, + } + ).insert() + + doc = make_dunning_type("_Test Dunning Disabled Account", income_account=disabled_account.name) + self.assertRaisesRegex(frappe.ValidationError, "is disabled", doc.insert) + + def test_income_account_must_be_income_type(self): + doc = make_dunning_type("_Test Dunning Non Income Account", income_account="Debtors - _TC") + self.assertRaisesRegex(frappe.ValidationError, "is not an Income Account", doc.insert) + + def test_income_account_must_not_be_group(self): + doc = make_dunning_type("_Test Dunning Group Account", income_account="Income - _TC") + self.assertRaisesRegex(frappe.ValidationError, "is a group account", doc.insert) + + def test_income_account_is_optional(self): + doc = make_dunning_type("_Test Dunning No Income Account", income_account=False) + doc.insert() + self.assertFalse(doc.income_account) + + def test_valid_income_account_passes(self): + doc = make_dunning_type("_Test Dunning Valid Income Account", income_account="Sales - _TC") + doc.insert() + self.assertEqual(doc.income_account, "Sales - _TC") + + def test_cost_center_must_belong_to_company(self): + doc = make_dunning_type("_Test Dunning Wrong Company CC", cost_center="Main - _TC1") + self.assertRaisesRegex(frappe.ValidationError, "doesn't belong to Company", doc.insert) + + def test_cost_center_must_not_be_disabled(self): + disabled_cc = frappe.get_doc( + { + "doctype": "Cost Center", + "cost_center_name": "_Test Disabled Cost Center", + "parent_cost_center": "_Test Company - _TC", + "company": "_Test Company", + "disabled": 1, + } + ).insert() + + doc = make_dunning_type("_Test Dunning Disabled CC", cost_center=disabled_cc.name) + self.assertRaisesRegex(frappe.ValidationError, "is disabled", doc.insert) + + def test_cost_center_must_not_be_group(self): + doc = make_dunning_type("_Test Dunning Group CC", cost_center="_Test Company - _TC") + self.assertRaisesRegex(frappe.ValidationError, "is a group Cost Center", doc.insert) + + def test_cost_center_is_optional(self): + doc = make_dunning_type("_Test Dunning No CC", cost_center=False) + doc.insert() + self.assertFalse(doc.cost_center) + + def test_valid_cost_center_passes(self): + doc = make_dunning_type("_Test Dunning Valid CC", cost_center="Main - _TC") + doc.insert() + self.assertEqual(doc.cost_center, "Main - _TC") + + def test_duplicate_languages_not_allowed(self): + doc = make_dunning_type( + "_Test Dunning Duplicate Language", + dunning_letter_text=[ + {"language": "en", "body_text": "Body one"}, + {"language": "en", "body_text": "Body two"}, + ], + ) + self.assertRaisesRegex(frappe.ValidationError, "Duplicate languages found", doc.insert) + + def test_unique_languages_allowed(self): + doc = make_dunning_type( + "_Test Dunning Unique Languages", + dunning_letter_text=[ + {"language": "en", "body_text": "Body one"}, + {"language": "de", "body_text": "Body two"}, + ], + ) + doc.insert() + self.assertEqual(len(doc.dunning_letter_text), 2) + + def test_only_one_default_language_allowed(self): + doc = make_dunning_type( + "_Test Dunning Multiple Default Language", + dunning_letter_text=[ + {"language": "en", "body_text": "Body one", "is_default_language": 1}, + {"language": "de", "body_text": "Body two", "is_default_language": 1}, + ], + ) + self.assertRaisesRegex( + frappe.ValidationError, "languages are marked as default languages", doc.insert + ) + + def test_single_default_language_allowed(self): + doc = make_dunning_type( + "_Test Dunning Single Default Language", + dunning_letter_text=[ + {"language": "en", "body_text": "Body one", "is_default_language": 1}, + {"language": "de", "body_text": "Body two", "is_default_language": 0}, + ], + ) + doc.insert() + self.assertEqual(doc.dunning_letter_text[0].is_default_language, 1) + + def test_invalid_jinja_template_in_body_text_raises(self): + doc = make_dunning_type( + "_Test Dunning Invalid Body Template", + dunning_letter_text=[{"language": "en", "body_text": "{{ unclosed"}], + ) + self.assertRaisesRegex(frappe.ValidationError, "Syntax error in template", doc.insert) + + def test_invalid_jinja_template_in_closing_text_raises(self): + doc = make_dunning_type( + "_Test Dunning Invalid Closing Template", + dunning_letter_text=[ + {"language": "en", "body_text": "Valid body", "closing_text": "{{ unclosed"} + ], + ) + self.assertRaisesRegex(frappe.ValidationError, "Syntax error in template", doc.insert) + + def test_valid_jinja_template_passes(self): + doc = make_dunning_type( + "_Test Dunning Valid Template", + dunning_letter_text=[ + { + "language": "en", + "body_text": "Outstanding amount is {{ outstanding_amount }}", + "closing_text": "Regards, {{ company }}", + } + ], + ) + doc.insert() + self.assertTrue(doc.name) + + def test_set_default_dunning_type_unsets_previous_default(self): + first = make_dunning_type("_Test Dunning Default One", is_default=1) + first.insert() + self.assertEqual(frappe.db.get_value("Dunning Type", first.name, "is_default"), 1) + + second = make_dunning_type("_Test Dunning Default Two", is_default=1) + second.insert() + + self.assertEqual(frappe.db.get_value("Dunning Type", first.name, "is_default"), 0) + self.assertEqual(frappe.db.get_value("Dunning Type", second.name, "is_default"), 1) + + def test_set_default_dunning_type_scoped_per_company(self): + company_1 = make_dunning_type("_Test Dunning Default Co1", is_default=1) + company_1.insert() + + company_2 = make_dunning_type( + "_Test Dunning Default Co2", + company="_Test Company 1", + is_default=1, + ) + company_2.insert() + + self.assertEqual(frappe.db.get_value("Dunning Type", company_1.name, "is_default"), 1) + self.assertEqual(frappe.db.get_value("Dunning Type", company_2.name, "is_default"), 1)