From e657a7f19f71fb8a0f3b4e807d1b515ccb923336 Mon Sep 17 00:00:00 2001 From: nishkagosalia Date: Tue, 28 Jul 2026 17:38:32 +0530 Subject: [PATCH 1/2] fix: handling negative grand total (cherry picked from commit 136f92db042f4c75ebcead930814d0b76d668116) --- .../sales_invoice/test_sales_invoice.py | 8 ++++++++ .../purchase_order/test_purchase_order.py | 13 ++++++++++++ erpnext/controllers/accounts_controller.py | 20 ++++++++++++++++++- erpnext/controllers/status_updater.py | 5 +++-- .../doctype/sales_order/test_sales_order.py | 13 ++++++++++++ 5 files changed, 56 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index ea1bc3195fc..617604582b5 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -108,6 +108,14 @@ class TestSalesInvoice(ERPNextTestSuite): si.save() self.assertEqual(si.items[0].qty, 1) + @ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1}) + def test_sales_invoice_negative_grand_total_still_blocked_with_setting(self): + """allow_negative_rates_for_items must not bypass the >=0 guard for a non-return + invoice, since invoices post to the GL (unlike Sales Order).""" + si = create_sales_invoice(qty=1, rate=100, do_not_save=True) + si.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150}) + self.assertRaises(frappe.ValidationError, si.save) + def test_timestamp_change(self): w = frappe.copy_doc(self.globalTestRecords["Sales Invoice"][0]) w.docstatus = 0 diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 2cc53d6ad95..28996602552 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -54,6 +54,19 @@ class TestPurchaseOrder(ERPNextTestSuite): po.save() self.assertEqual(po.items[1].qty, 1) + def test_purchase_order_negative_grand_total_blocked_by_default(self): + po = create_purchase_order(qty=1, rate=100, do_not_save=True) + po.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150, "schedule_date": nowdate()}) + self.assertRaises(frappe.ValidationError, po.save) + + @ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 1}) + def test_purchase_order_negative_grand_total_allowed_with_setting(self): + """A supplier change order can net to a negative grand total (credit owed).""" + po = create_purchase_order(qty=1, rate=100, do_not_save=True) + po.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150, "schedule_date": nowdate()}) + po.save() + self.assertTrue(po.base_grand_total < 0) + def test_purchase_order_zero_qty(self): po = create_purchase_order(qty=0, do_not_save=True) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index dd4f51025f7..a3ec879bbea 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -241,6 +241,23 @@ class AccountsController(TransactionBase): ) frappe.msgprint(msg) + def is_negative_grand_total_allowed(self) -> bool: + """Return True if this document may save with a negative grand total. + + Sales Order and Purchase Order never post to the GL, so a negative + total is safe there whenever the user has explicitly opted into + negative rates via Selling/Buying Settings. Every other + AccountsController doctype (invoices, delivery notes, receipts, + quotations, ...) keeps relying on the `is_return` escape hatch only. + """ + if self.doctype == "Sales Order": + return bool(frappe.get_single_value("Selling Settings", "allow_negative_rates_for_items")) + + if self.doctype == "Purchase Order": + return bool(frappe.get_single_value("Buying Settings", "allow_negative_rates_for_items")) + + return False + def validate(self): if not self.get("is_return") and not self.get("is_debit_note"): self.validate_qty_is_not_zero() @@ -290,7 +307,8 @@ class AccountsController(TransactionBase): self.calculate_taxes_and_totals() if not self.meta.get_field("is_return") or not self.is_return: - self.validate_value("base_grand_total", ">=", 0) + if not self.is_negative_grand_total_allowed(): + self.validate_value("base_grand_total", ">=", 0) validate_return(self) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index ddea50fb7ff..4781eee012c 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -292,10 +292,11 @@ class StatusUpdater(Document): frappe.throw(_("For an item {0}, quantity must be negative number").format(d.item_code)) if ( - not selling_negative_rate_allowed and self.doctype in ["Sales Invoice", "Delivery Note"] + not selling_negative_rate_allowed + and self.doctype in ["Sales Order", "Sales Invoice", "Delivery Note"] ) or ( not buying_negative_rate_allowed - and self.doctype in ["Purchase Invoice", "Purchase Receipt"] + and self.doctype in ["Purchase Order", "Purchase Invoice", "Purchase Receipt"] ): if hasattr(d, "item_code") and hasattr(d, "rate") and flt(d.rate) < 0: frappe.throw( diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 57d6fde087b..cff7fe23ee2 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -157,6 +157,19 @@ class TestSalesOrder(ERPNextTestSuite): ) update_child_qty_rate("Sales Order", trans_item, so.name) + def test_sales_order_negative_grand_total_blocked_by_default(self): + so = make_sales_order(qty=1, rate=100, do_not_save=True) + so.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150}) + self.assertRaises(frappe.ValidationError, so.save) + + @ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1}) + def test_sales_order_negative_grand_total_allowed_with_setting(self): + """A subscription downgrade / change order can net to a negative grand total.""" + so = make_sales_order(qty=1, rate=100, do_not_save=True) + so.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150}) + so.save() + self.assertTrue(so.base_grand_total < 0) + @ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1}) def test_sales_order_qty(self): so = make_sales_order(qty=1, do_not_save=True) From 51aecec598e7c850ec20e7277f77a4e3ef132e9d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 16:29:28 +0530 Subject: [PATCH 2/2] fix(controllers): correct negative rate settings link (cherry picked from commit 4089f138f21576f0c4553547ce0c647ef9a270b2) --- .../purchase_order/test_purchase_order.py | 13 +++++++++-- erpnext/controllers/status_updater.py | 13 +++++------ .../doctype/sales_order/test_sales_order.py | 23 +++++++++++++++++-- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 28996602552..409e02f9eda 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -54,19 +54,28 @@ class TestPurchaseOrder(ERPNextTestSuite): po.save() self.assertEqual(po.items[1].qty, 1) - def test_purchase_order_negative_grand_total_blocked_by_default(self): + @ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 0}) + def test_purchase_order_negative_grand_total_blocked_without_setting(self): po = create_purchase_order(qty=1, rate=100, do_not_save=True) po.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150, "schedule_date": nowdate()}) self.assertRaises(frappe.ValidationError, po.save) @ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 1}) def test_purchase_order_negative_grand_total_allowed_with_setting(self): - """A supplier change order can net to a negative grand total (credit owed).""" + """Use a negative rate to represent a credit while order quantities remain positive.""" po = create_purchase_order(qty=1, rate=100, do_not_save=True) po.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150, "schedule_date": nowdate()}) po.save() + po.submit() + self.assertEqual(po.docstatus, 1) self.assertTrue(po.base_grand_total < 0) + @ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 1}) + def test_purchase_order_negative_rate_setting_does_not_allow_negative_quantity(self): + po = create_purchase_order(qty=1, rate=100, do_not_save=True) + po.append("items", {"item_code": "_Test Item 2", "qty": -1, "rate": 100}) + self.assertRaises(frappe.ValidationError, po.save) + def test_purchase_order_zero_qty(self): po = create_purchase_order(qty=0, do_not_save=True) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 4781eee012c..786bf5ccd60 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -264,6 +264,9 @@ class StatusUpdater(Document): def validate_qty(self): """Validates qty at row level""" + selling_doctypes = ("Sales Order", "Sales Invoice", "Delivery Note") + buying_doctypes = ("Purchase Order", "Purchase Invoice", "Purchase Receipt") + for args in self.status_updater: if "target_ref_field" not in args or args.get("validate_qty") is False: # if target_ref_field is not specified or validate_qty is explicitly set to False, skip validation @@ -291,12 +294,8 @@ class StatusUpdater(Document): if hasattr(d, "qty") and flt(d.qty) > 0 and self.get("is_return"): frappe.throw(_("For an item {0}, quantity must be negative number").format(d.item_code)) - if ( - not selling_negative_rate_allowed - and self.doctype in ["Sales Order", "Sales Invoice", "Delivery Note"] - ) or ( - not buying_negative_rate_allowed - and self.doctype in ["Purchase Order", "Purchase Invoice", "Purchase Receipt"] + if (not selling_negative_rate_allowed and self.doctype in selling_doctypes) or ( + not buying_negative_rate_allowed and self.doctype in buying_doctypes ): if hasattr(d, "item_code") and hasattr(d, "rate") and flt(d.rate) < 0: frappe.throw( @@ -307,7 +306,7 @@ class StatusUpdater(Document): frappe.bold(_("`Allow Negative rates for Items`")), get_link_to_form( "Selling Settings" - if self.doctype in ["Sales Invoice", "Delivery Note"] + if self.doctype in selling_doctypes else "Buying Settings" ), ), diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index cff7fe23ee2..c13e899c1c4 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -157,19 +157,38 @@ class TestSalesOrder(ERPNextTestSuite): ) update_child_qty_rate("Sales Order", trans_item, so.name) - def test_sales_order_negative_grand_total_blocked_by_default(self): + @ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 0}) + def test_sales_order_negative_grand_total_blocked_without_setting(self): so = make_sales_order(qty=1, rate=100, do_not_save=True) so.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150}) self.assertRaises(frappe.ValidationError, so.save) @ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1}) def test_sales_order_negative_grand_total_allowed_with_setting(self): - """A subscription downgrade / change order can net to a negative grand total.""" + """Use a negative rate to represent a credit while order quantities remain positive.""" so = make_sales_order(qty=1, rate=100, do_not_save=True) so.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150}) so.save() + so.submit() + self.assertEqual(so.docstatus, 1) self.assertTrue(so.base_grand_total < 0) + @ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 0}) + def test_sales_order_negative_rate_error_links_to_selling_settings(self): + so = make_sales_order(qty=1, rate=100, do_not_save=True) + so.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -10}) + so.save() + + with self.assertRaises(frappe.ValidationError) as error: + so.submit() + + self.assertIn("selling-settings", str(error.exception)) + + @ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1}) + def test_sales_order_negative_rate_setting_does_not_allow_negative_quantity(self): + so = make_sales_order(qty=-1, rate=100, do_not_save=True) + self.assertRaises(frappe.NonNegativeError, so.save) + @ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1}) def test_sales_order_qty(self): so = make_sales_order(qty=1, do_not_save=True)