From 224cf19f3331976178e89990fe92e12ad0db633f Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 30 Jun 2026 22:22:17 +0530 Subject: [PATCH 1/2] fix(selling): update sales order per billed on credit note submission --- erpnext/stock/doctype/delivery_note/delivery_note.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 8fcc66c19dd..c9c319d9d1a 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -9,6 +9,7 @@ from frappe import _ from frappe.contacts.doctype.address.address import get_company_address from frappe.contacts.doctype.contact.contact import get_default_contact from frappe.desk.notifications import clear_doctype_notifications +from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.model.utils import get_fetch_values from frappe.query_builder import DocType @@ -813,7 +814,9 @@ def get_returned_qty_map(delivery_note): @frappe.whitelist() -def make_sales_invoice(source_name, target_doc=None, args=None): +def make_sales_invoice( + source_name: str, target_doc: Document | str | None = None, args: dict | str | None = None +): if args is None: args = {} if isinstance(args, str): @@ -919,7 +922,12 @@ def make_sales_invoice(source_name, target_doc=None, args=None): frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") ) - if not doc.is_return: + if doc.is_return: + # A credit note made from a return Delivery Note should roll back the billed + # amount on the linked Sales Order too, so that per_billed stays consistent with + # per_delivered (which the return already reset). + doc.update_billed_amount_in_sales_order = True + else: so, doctype, fieldname = doc.get_order_details() if ( doc.linked_order_has_payment_terms(so, fieldname, doctype) From 710e0216382f4c596da2c074c103ec8c4d591f1e Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 30 Jun 2026 22:23:43 +0530 Subject: [PATCH 2/2] test(selling): add test to validate the per billed after credit note submission --- .../delivery_note/test_delivery_note.py | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index a9c3970e0cc..a158ab3c4d0 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -2627,6 +2627,92 @@ class TestDeliveryNote(ERPNextTestSuite): self.assertEqual(dn.per_returned, 100) self.assertEqual(returned.status, "Return") + def _assert_credit_note_from_return_dn_resets_per_billed(self, so, dn): + """Given a fully billed Sales Order and a submitted Delivery Note that delivers it, + a credit note made from the return of that Delivery Note must reset per_billed to 0 + while leaving the delivery quantities exactly as the return already set them.""" + from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + + so.load_from_db() + self.assertEqual(so.per_delivered, 100) + self.assertEqual(so.per_billed, 100) + + return_dn = make_sales_return(dn.name) + return_dn.insert() + return_dn.submit() + + # the return reverses the delivery quantities + so.load_from_db() + self.assertEqual(so.per_delivered, 0) + self.assertEqual(so.items[0].delivered_qty, 0) + + credit_note = make_sales_invoice(return_dn.name) + self.assertTrue(credit_note.is_return) + self.assertTrue(credit_note.update_billed_amount_in_sales_order) + # A Delivery Note-linked invoice can't update stock (validate_delivery_note), so the + # credit note only rolls back billing and never re-reverses the delivery quantities. + self.assertFalse(credit_note.update_stock) + credit_note.insert() + credit_note.submit() + + # per_billed is reset, and the delivery state stays exactly as the return left it + so.load_from_db() + self.assertEqual(so.per_billed, 0) + self.assertEqual(so.per_delivered, 0) + self.assertEqual(so.items[0].delivered_qty, 0) + self.assertEqual(so.items[0].returned_qty, 0) + + # Cancelling the credit note should restore the billed amount on the Sales Order. + credit_note.cancel() + so.load_from_db() + self.assertEqual(so.per_billed, 100) + + def test_sales_order_per_billed_after_credit_note_from_return_dn(self): + # Reported flow: SO -> SI (from SO) -> DN (from SI) -> return DN -> credit note. + # The DN carries si_detail in this path. + from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note + from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so + + make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100) + + so = make_sales_order(qty=2) + + si = make_si_from_so(so.name) + si.insert() + si.submit() + + dn = make_delivery_note(si.name) + dn.insert() + dn.submit() + + self._assert_credit_note_from_return_dn_resets_per_billed(so, dn) + + def test_sales_order_per_billed_after_credit_note_from_so_derived_dn(self): + # SO billed and delivered separately (SO -> SI, SO -> DN), then return DN -> credit note. + # SO per_billed rolls back via the status_updater in update_prevdoc_status. + from erpnext.selling.doctype.sales_order.sales_order import ( + make_delivery_note as make_dn_from_so, + ) + from erpnext.selling.doctype.sales_order.sales_order import ( + make_sales_invoice as make_si_from_so, + ) + + make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100) + + so = make_sales_order(qty=2) + + si = make_si_from_so(so.name) + si.insert() + si.submit() + + dn = make_dn_from_so(so.name) + dn.insert() + dn.submit() + + self.assertIsNone(dn.items[0].si_detail) + + self._assert_credit_note_from_return_dn_resets_per_billed(so, dn) + def test_packed_item_serial_no_status(self): from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle from erpnext.stock.doctype.item.test_item import make_item