diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py index 48f12897045..f64e957400b 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 @@ -163,43 +164,66 @@ class Dunning(AccountsController): ] -def resolve_dunning(doc, state): - """ - Check if all payments have been made and resolve dunning, if yes. Called - when a Payment Entry is submitted. - """ - 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 +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 - 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) + to_resolve = doc.outstanding_amount < previous_outstanding_amount + state = "Unresolved" if to_resolve else "Resolved" + dunnings = get_linked_dunnings_as_per_state(doc.name, state) + if not dunnings: + return - 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) + dunnings = [frappe.get_doc("Dunning", dunning.name) for dunning in dunnings] + invoices = set() + payment_schedule_ids = set() - new_status = "Resolved" if resolve else "Unresolved" + for dunning in dunnings: + for overdue_payment in dunning.overdue_payments: + invoices.add(overdue_payment.sales_invoice) + if overdue_payment.payment_schedule: + payment_schedule_ids.add(overdue_payment.payment_schedule) - if dunning.status != new_status: - dunning.status = new_status - dunning.save() + 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 else "Unresolved" + + if dunning.status != new_status: + dunning.status = new_status + dunning.save() def get_linked_dunnings_as_per_state(sales_invoice, state): diff --git a/erpnext/accounts/doctype/dunning/test_dunning.py b/erpnext/accounts/doctype/dunning/test_dunning.py index 9c0885a5bd0..4ba44a2a3c9 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_from_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/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): 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 806955d6e7c..2bbd0f2c7da 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -360,7 +360,9 @@ doc_events = { "erpnext.regional.create_transaction_log", "erpnext.regional.italy.utils.sales_invoice_on_submit", ], - "on_cancel": ["erpnext.regional.italy.utils.sales_invoice_on_cancel"], + "on_cancel": [ + "erpnext.regional.italy.utils.sales_invoice_on_cancel", + ], "on_trash": "erpnext.regional.check_deletion_permission", }, "Purchase Invoice": { @@ -372,9 +374,7 @@ doc_events = { "Payment Entry": { "on_submit": [ "erpnext.regional.create_transaction_log", - "erpnext.accounts.doctype.dunning.dunning.resolve_dunning", ], - "on_cancel": ["erpnext.accounts.doctype.dunning.dunning.resolve_dunning"], "on_trash": "erpnext.regional.check_deletion_permission", }, "Address": { 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, )