From b304c1d0793c6424aa6e287ea2cba6a465476c33 Mon Sep 17 00:00:00 2001 From: Karm Soni Date: Fri, 8 Aug 2025 18:17:19 +0530 Subject: [PATCH 1/6] feat: add dunning resolution for credit notes and update hooks --- erpnext/accounts/doctype/dunning/dunning.py | 41 +++++++++++++ .../accounts/doctype/dunning/test_dunning.py | 58 +++++++++++++++++++ erpnext/hooks.py | 6 +- 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py index 48f12897045..379c7e96e66 100644 --- a/erpnext/accounts/doctype/dunning/dunning.py +++ b/erpnext/accounts/doctype/dunning/dunning.py @@ -11,6 +11,7 @@ -> Resolves dunning automatically """ + import json import frappe @@ -202,6 +203,46 @@ def resolve_dunning(doc, state): dunning.save() +def resolve_dunning_for_credit_note(doc, state): + """ + Check if dunning should be resolved when a credit note is issued against a Sales Invoice. + Only process if update_outstanding_for_self is False (credit note is being applied against the original invoice). + """ + if not doc.is_return or doc.get("update_outstanding_for_self"): + return + + if not doc.get("return_against"): + return + + original_invoice = doc.return_against + if doc.docstatus == 1: + state = "Unresolved" + elif doc.docstatus == 2: + state = "Resolved" + else: + return + + dunnings = get_linked_dunnings_as_per_state(original_invoice, state) + + for dunning in dunnings: + resolve = True + dunning = frappe.get_doc("Dunning", dunning.get("name")) + for overdue_payment in dunning.overdue_payments: + outstanding_inv = frappe.get_value( + "Sales Invoice", overdue_payment.sales_invoice, "outstanding_amount" + ) + outstanding_ps = frappe.get_value( + "Payment Schedule", overdue_payment.payment_schedule, "outstanding" + ) + resolve = resolve and (False if (outstanding_ps > 0 and outstanding_inv > 0) else True) + + new_status = "Resolved" if (resolve and doc.docstatus == 1) else "Unresolved" + + if dunning.status != new_status: + dunning.status = new_status + dunning.save() + + def get_linked_dunnings_as_per_state(sales_invoice, state): dunning = frappe.qb.DocType("Dunning") overdue_payment = frappe.qb.DocType("Overdue Payment") diff --git a/erpnext/accounts/doctype/dunning/test_dunning.py b/erpnext/accounts/doctype/dunning/test_dunning.py index 9c0885a5bd0..c8b5a631864 100644 --- a/erpnext/accounts/doctype/dunning/test_dunning.py +++ b/erpnext/accounts/doctype/dunning/test_dunning.py @@ -139,6 +139,64 @@ class TestDunning(IntegrationTestCase): self.assertEqual(sales_invoice.status, "Overdue") self.assertEqual(dunning.status, "Unresolved") + def test_dunning_resolution_with_credit_note(self): + """ + Test that dunning is resolved when a credit note is issued against the original invoice. + """ + sales_invoice = create_sales_invoice_against_cost_center( + posting_date=add_days(today(), -10), qty=1, rate=100 + ) + dunning = create_dunning_from_sales_invoice(sales_invoice.name) + dunning.submit() + + self.assertEqual(dunning.status, "Unresolved") + + credit_note = frappe.copy_doc(sales_invoice) + credit_note.is_return = 1 + credit_note.return_against = sales_invoice.name + credit_note.update_outstanding_for_self = 0 + + for item in credit_note.items: + item.qty = -item.qty + + credit_note.save() + credit_note.submit() + + dunning.reload() + self.assertEqual(dunning.status, "Resolved") + + credit_note.cancel() + dunning.reload() + self.assertEqual(dunning.status, "Unresolved") + + def test_dunning_not_affected_by_standalone_credit_note(self): + """ + Test that dunning is NOT resolved when a credit note has update_outstanding_for_self checked. + """ + sales_invoice = create_sales_invoice_against_cost_center( + posting_date=add_days(today(), -10), qty=1, rate=100 + ) + dunning = create_dunning_from_sales_invoice(sales_invoice.name) + dunning.submit() + + self.assertEqual(dunning.status, "Unresolved") + + credit_note = frappe.copy_doc(sales_invoice) + credit_note.is_return = 1 + credit_note.return_against = sales_invoice.name + credit_note.update_outstanding_for_self = 1 + + for item in credit_note.items: + item.qty = -item.qty + + credit_note.save() + + credit_note = frappe.get_doc("Sales Invoice", credit_note.name) + credit_note.submit() + + dunning.reload() + self.assertEqual(dunning.status, "Unresolved") + def create_dunning(overdue_days, dunning_type_name=None): posting_date = add_days(today(), -1 * overdue_days) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 806955d6e7c..5006f143377 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -359,8 +359,12 @@ doc_events = { "on_submit": [ "erpnext.regional.create_transaction_log", "erpnext.regional.italy.utils.sales_invoice_on_submit", + "erpnext.accounts.doctype.dunning.dunning.resolve_dunning_for_credit_note", + ], + "on_cancel": [ + "erpnext.regional.italy.utils.sales_invoice_on_cancel", + "erpnext.accounts.doctype.dunning.dunning.resolve_dunning_for_credit_note", ], - "on_cancel": ["erpnext.regional.italy.utils.sales_invoice_on_cancel"], "on_trash": "erpnext.regional.check_deletion_permission", }, "Purchase Invoice": { From 6c644dd5d212cadbe94d5c32dbe4e1cbab003199 Mon Sep 17 00:00:00 2001 From: Karm Soni Date: Thu, 14 Aug 2025 16:34:14 +0530 Subject: [PATCH 2/6] refactor: combine the return conditions --- erpnext/accounts/doctype/dunning/dunning.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py index 379c7e96e66..5166190e4a5 100644 --- a/erpnext/accounts/doctype/dunning/dunning.py +++ b/erpnext/accounts/doctype/dunning/dunning.py @@ -208,21 +208,12 @@ def resolve_dunning_for_credit_note(doc, state): Check if dunning should be resolved when a credit note is issued against a Sales Invoice. Only process if update_outstanding_for_self is False (credit note is being applied against the original invoice). """ - if not doc.is_return or doc.get("update_outstanding_for_self"): + if not doc.is_return or doc.get("update_outstanding_for_self") or not doc.get("return_against"): return - if not doc.get("return_against"): - return + state = "Resolved" if doc.docstatus == 2 else "Unresolved" - original_invoice = doc.return_against - if doc.docstatus == 1: - state = "Unresolved" - elif doc.docstatus == 2: - state = "Resolved" - else: - return - - dunnings = get_linked_dunnings_as_per_state(original_invoice, state) + dunnings = get_linked_dunnings_as_per_state(doc.return_against, state) for dunning in dunnings: resolve = True From fe2d0ea43b77084e9bc58d40bae5c7bbcb14c573 Mon Sep 17 00:00:00 2001 From: Sagar Vora <16315650+sagarvora@users.noreply.github.com> Date: Thu, 14 Aug 2025 17:44:32 +0530 Subject: [PATCH 3/6] refactor: commonify and improve perf --- erpnext/accounts/doctype/dunning/dunning.py | 112 ++++++++++-------- .../accounts/doctype/dunning/test_dunning.py | 2 +- erpnext/hooks.py | 8 +- 3 files changed, 70 insertions(+), 52 deletions(-) diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py index 5166190e4a5..9580bbfbf39 100644 --- a/erpnext/accounts/doctype/dunning/dunning.py +++ b/erpnext/accounts/doctype/dunning/dunning.py @@ -164,70 +164,88 @@ class Dunning(AccountsController): ] -def resolve_dunning(doc, state): +def resolve_dunnings(doc, method=None): """ - Check if all payments have been made and resolve dunning, if yes. Called - when a Payment Entry is submitted. + Resolve / unresolve Dunning based on whether all payments have been made. + Called when a Payment Entry / Credit Note is submitted / cancelled. """ + + match doc.doctype: + case "Payment Entry": + return resolve_dunnings_from_payment_entry(doc) + case "Sales Invoice": + return resolve_dunnings_from_credit_note(doc) + + +def resolve_dunnings_from_payment_entry(doc): + is_submitted = doc.docstatus == 1 + for reference in doc.references: - # Consider partial and full payments: - # Submitting full payment: outstanding_amount will be 0 - # Submitting 1st partial payment: outstanding_amount will be the pending installment - # Cancelling full payment: outstanding_amount will revert to total amount - # Cancelling last partial payment: outstanding_amount will revert to pending amount - submit_condition = reference.outstanding_amount < reference.total_amount - cancel_condition = reference.outstanding_amount <= reference.total_amount + if reference.reference_doctype != "Sales Invoice" or not reference.allocated_amount: + continue - if reference.reference_doctype == "Sales Invoice" and ( - submit_condition if doc.docstatus == 1 else cancel_condition - ): - state = "Resolved" if doc.docstatus == 2 else "Unresolved" - dunnings = get_linked_dunnings_as_per_state(reference.reference_name, state) - - for dunning in dunnings: - resolve = True - dunning = frappe.get_doc("Dunning", dunning.get("name")) - for overdue_payment in dunning.overdue_payments: - outstanding_inv = frappe.get_value( - "Sales Invoice", overdue_payment.sales_invoice, "outstanding_amount" - ) - outstanding_ps = frappe.get_value( - "Payment Schedule", overdue_payment.payment_schedule, "outstanding" - ) - resolve = resolve and (False if (outstanding_ps > 0 and outstanding_inv > 0) else True) - - new_status = "Resolved" if resolve else "Unresolved" - - if dunning.status != new_status: - dunning.status = new_status - dunning.save() + _update_linked_dunnings(reference.reference_name, to_resolve=is_submitted) -def resolve_dunning_for_credit_note(doc, state): +def resolve_dunnings_from_credit_note(doc): """ Check if dunning should be resolved when a credit note is issued against a Sales Invoice. Only process if update_outstanding_for_self is False (credit note is being applied against the original invoice). """ - if not doc.is_return or doc.get("update_outstanding_for_self") or not doc.get("return_against"): + if not doc.is_return or doc.update_outstanding_for_self or not doc.return_against: return - state = "Resolved" if doc.docstatus == 2 else "Unresolved" + _update_linked_dunnings(doc.return_against, to_resolve=doc.docstatus == 1) - dunnings = get_linked_dunnings_as_per_state(doc.return_against, state) + +def _update_linked_dunnings(sales_invoice: str, to_resolve: bool = True): + state = "Unresolved" if to_resolve else "Resolved" + dunnings = get_linked_dunnings_as_per_state(sales_invoice, state) + if not dunnings: + return + + dunnings = [frappe.get_doc("Dunning", dunning.name) for dunning in dunnings] + invoices = set() + payment_schedule_ids = set() for dunning in dunnings: - resolve = True - dunning = frappe.get_doc("Dunning", dunning.get("name")) for overdue_payment in dunning.overdue_payments: - outstanding_inv = frappe.get_value( - "Sales Invoice", overdue_payment.sales_invoice, "outstanding_amount" - ) - outstanding_ps = frappe.get_value( - "Payment Schedule", overdue_payment.payment_schedule, "outstanding" - ) - resolve = resolve and (False if (outstanding_ps > 0 and outstanding_inv > 0) else True) + invoices.add(overdue_payment.sales_invoice) + if overdue_payment.payment_schedule: + payment_schedule_ids.add(overdue_payment.payment_schedule) - new_status = "Resolved" if (resolve and doc.docstatus == 1) else "Unresolved" + invoice_outstanding_amounts = dict( + frappe.get_all( + "Sales Invoice", + filters={"name": ["in", list(invoices)]}, + fields=["name", "outstanding_amount"], + as_list=True, + ) + ) + + ps_outstanding_amounts = ( + dict( + frappe.get_all( + "Payment Schedule", + filters={"name": ["in", list(payment_schedule_ids)]}, + fields=["name", "outstanding"], + as_list=True, + ) + ) + if payment_schedule_ids + else {} + ) + + for dunning in dunnings: + has_outstanding = False + for overdue_payment in dunning.overdue_payments: + invoice_outstanding = invoice_outstanding_amounts[overdue_payment.sales_invoice] + ps_outstanding = ps_outstanding_amounts.get(overdue_payment.payment_schedule, 0) + has_outstanding = invoice_outstanding > 0 and ps_outstanding > 0 + if has_outstanding: + break + + new_status = "Resolved" if (not has_outstanding and to_resolve) else "Unresolved" if dunning.status != new_status: dunning.status = new_status diff --git a/erpnext/accounts/doctype/dunning/test_dunning.py b/erpnext/accounts/doctype/dunning/test_dunning.py index c8b5a631864..4ba44a2a3c9 100644 --- a/erpnext/accounts/doctype/dunning/test_dunning.py +++ b/erpnext/accounts/doctype/dunning/test_dunning.py @@ -139,7 +139,7 @@ class TestDunning(IntegrationTestCase): self.assertEqual(sales_invoice.status, "Overdue") self.assertEqual(dunning.status, "Unresolved") - def test_dunning_resolution_with_credit_note(self): + def test_dunning_resolution_from_credit_note(self): """ Test that dunning is resolved when a credit note is issued against the original invoice. """ diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 5006f143377..b99f685aaeb 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -359,11 +359,11 @@ doc_events = { "on_submit": [ "erpnext.regional.create_transaction_log", "erpnext.regional.italy.utils.sales_invoice_on_submit", - "erpnext.accounts.doctype.dunning.dunning.resolve_dunning_for_credit_note", + "erpnext.accounts.doctype.dunning.dunning.resolve_dunnings", ], "on_cancel": [ "erpnext.regional.italy.utils.sales_invoice_on_cancel", - "erpnext.accounts.doctype.dunning.dunning.resolve_dunning_for_credit_note", + "erpnext.accounts.doctype.dunning.dunning.resolve_dunnings", ], "on_trash": "erpnext.regional.check_deletion_permission", }, @@ -376,9 +376,9 @@ doc_events = { "Payment Entry": { "on_submit": [ "erpnext.regional.create_transaction_log", - "erpnext.accounts.doctype.dunning.dunning.resolve_dunning", + "erpnext.accounts.doctype.dunning.dunning.resolve_dunnings", ], - "on_cancel": ["erpnext.accounts.doctype.dunning.dunning.resolve_dunning"], + "on_cancel": ["erpnext.accounts.doctype.dunning.dunning.resolve_dunnings"], "on_trash": "erpnext.regional.check_deletion_permission", }, "Address": { From d959ca1694d2639caa71c90c433e96868e680c7b Mon Sep 17 00:00:00 2001 From: Sagar Vora <16315650+sagarvora@users.noreply.github.com> Date: Mon, 18 Aug 2025 12:21:32 +0530 Subject: [PATCH 4/6] fix: handle dunning status change on all changes to outstanding amount --- erpnext/accounts/doctype/dunning/dunning.py | 44 +++++---------------- erpnext/accounts/utils.py | 4 ++ erpnext/hooks.py | 4 -- 3 files changed, 13 insertions(+), 39 deletions(-) diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py index 9580bbfbf39..f64e957400b 100644 --- a/erpnext/accounts/doctype/dunning/dunning.py +++ b/erpnext/accounts/doctype/dunning/dunning.py @@ -164,43 +164,17 @@ class Dunning(AccountsController): ] -def resolve_dunnings(doc, method=None): - """ - Resolve / unresolve Dunning based on whether all payments have been made. - Called when a Payment Entry / Credit Note is submitted / cancelled. - """ - - match doc.doctype: - case "Payment Entry": - return resolve_dunnings_from_payment_entry(doc) - case "Sales Invoice": - return resolve_dunnings_from_credit_note(doc) - - -def resolve_dunnings_from_payment_entry(doc): - is_submitted = doc.docstatus == 1 - - for reference in doc.references: - if reference.reference_doctype != "Sales Invoice" or not reference.allocated_amount: - continue - - _update_linked_dunnings(reference.reference_name, to_resolve=is_submitted) - - -def resolve_dunnings_from_credit_note(doc): - """ - Check if dunning should be resolved when a credit note is issued against a Sales Invoice. - Only process if update_outstanding_for_self is False (credit note is being applied against the original invoice). - """ - if not doc.is_return or doc.update_outstanding_for_self or not doc.return_against: +def update_linked_dunnings(doc, previous_outstanding_amount): + if ( + doc.doctype != "Sales Invoice" + or doc.is_return + or previous_outstanding_amount == doc.outstanding_amount + ): return - _update_linked_dunnings(doc.return_against, to_resolve=doc.docstatus == 1) - - -def _update_linked_dunnings(sales_invoice: str, to_resolve: bool = True): + to_resolve = doc.outstanding_amount < previous_outstanding_amount state = "Unresolved" if to_resolve else "Resolved" - dunnings = get_linked_dunnings_as_per_state(sales_invoice, state) + dunnings = get_linked_dunnings_as_per_state(doc.name, state) if not dunnings: return @@ -245,7 +219,7 @@ def _update_linked_dunnings(sales_invoice: str, to_resolve: bool = True): if has_outstanding: break - new_status = "Resolved" if (not has_outstanding and to_resolve) else "Unresolved" + new_status = "Resolved" if not has_outstanding else "Unresolved" if dunning.status != new_status: dunning.status = new_status diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 9e855229f8b..ab0283b44bd 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -1940,6 +1940,8 @@ def create_payment_ledger_entry( def update_voucher_outstanding(voucher_type, voucher_no, account, party_type, party): + from erpnext.accounts.doctype.dunning.dunning import update_linked_dunnings + if not voucher_type or not voucher_no: return @@ -1969,6 +1971,7 @@ def update_voucher_outstanding(voucher_type, voucher_no, account, party_type, pa outstanding = voucher_outstanding[0] ref_doc = frappe.get_lazy_doc(voucher_type, voucher_no) + previous_outstanding_amount = ref_doc.outstanding_amount outstanding_amount = flt( outstanding["outstanding_in_account_currency"], ref_doc.precision("outstanding_amount") ) @@ -1982,6 +1985,7 @@ def update_voucher_outstanding(voucher_type, voucher_no, account, party_type, pa outstanding_amount, ) + update_linked_dunnings(ref_doc, previous_outstanding_amount) ref_doc.set_status(update=True) ref_doc.notify_update() diff --git a/erpnext/hooks.py b/erpnext/hooks.py index b99f685aaeb..2bbd0f2c7da 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -359,11 +359,9 @@ doc_events = { "on_submit": [ "erpnext.regional.create_transaction_log", "erpnext.regional.italy.utils.sales_invoice_on_submit", - "erpnext.accounts.doctype.dunning.dunning.resolve_dunnings", ], "on_cancel": [ "erpnext.regional.italy.utils.sales_invoice_on_cancel", - "erpnext.accounts.doctype.dunning.dunning.resolve_dunnings", ], "on_trash": "erpnext.regional.check_deletion_permission", }, @@ -376,9 +374,7 @@ doc_events = { "Payment Entry": { "on_submit": [ "erpnext.regional.create_transaction_log", - "erpnext.accounts.doctype.dunning.dunning.resolve_dunnings", ], - "on_cancel": ["erpnext.accounts.doctype.dunning.dunning.resolve_dunnings"], "on_trash": "erpnext.regional.check_deletion_permission", }, "Address": { From 059c54187551f177be953b67a277cebdefcb13f1 Mon Sep 17 00:00:00 2001 From: Karm Soni Date: Mon, 18 Aug 2025 13:31:47 +0530 Subject: [PATCH 5/6] fix: update payment schedule then make gl entries --- erpnext/accounts/doctype/payment_entry/payment_entry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index e2bd7e82886..07d129c26dd 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -200,9 +200,9 @@ class PaymentEntry(AccountsController): if self.difference_amount: frappe.throw(_("Difference Amount must be zero")) self.update_payment_requests() + self.update_payment_schedule() self.make_gl_entries() self.update_outstanding_amounts() - self.update_payment_schedule() self.set_status() def validate_for_repost(self): @@ -303,10 +303,10 @@ class PaymentEntry(AccountsController): ) super().on_cancel() self.update_payment_requests(cancel=True) + self.update_payment_schedule(cancel=1) self.make_gl_entries(cancel=1) self.update_outstanding_amounts() self.delink_advance_entry_references() - self.update_payment_schedule(cancel=1) self.set_status() def update_payment_requests(self, cancel=False): From 53520af2fd819315f9cda3bcbf9c8c3f17bd3e28 Mon Sep 17 00:00:00 2001 From: Karm Soni Date: Mon, 18 Aug 2025 15:48:43 +0530 Subject: [PATCH 6/6] test: update test data for internal customer and supplier --- .../doctype/inventory_dimension/test_inventory_dimension.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py index 98d4a43ff94..6adb5932f24 100644 --- a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py @@ -667,13 +667,13 @@ def prepare_data_for_internal_transfer(): company = "_Test Company with perpetual inventory" customer = create_internal_customer( - "_Test Internal Customer 3", + "_Test Internal Customer 2", company, company, ) supplier = create_internal_supplier( - "_Test Internal Supplier 3", + "_Test Internal Supplier 2", company, company, )