From 06bfc23436432c054c32aa86ece03d64e8dbbd27 Mon Sep 17 00:00:00 2001 From: Mohd Haris Date: Tue, 28 Jul 2026 13:04:31 +0530 Subject: [PATCH] fix: prevent TimestampMismatchError resolving Dunning with multiple overdue installments `get_linked_dunnings_as_per_state` joins Dunning to its Overdue Payment child table without DISTINCT. When a Sales Invoice has more than one overdue installment, its Dunning holds one Overdue Payment row per installment, so the query returns the same Dunning name once per row. `update_linked_dunnings` then loads that Dunning name into a separate document object for each duplicate row and saves each one. The first save bumps the `modified` timestamp, so the second (now stale) save fails with `TimestampMismatchError` ("Document has been modified after you have opened it"). The error is raised on the Dunning while the user is submitting a Payment Entry, making it confusing, and payments for such invoices cannot be posted at all. Add DISTINCT so each linked Dunning is returned (and saved) exactly once. Co-Authored-By: Claude Opus 4.8 --- erpnext/accounts/doctype/dunning/dunning.py | 1 + .../accounts/doctype/dunning/test_dunning.py | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py index dbe8ebcbcd2..c1186a01354 100644 --- a/erpnext/accounts/doctype/dunning/dunning.py +++ b/erpnext/accounts/doctype/dunning/dunning.py @@ -275,6 +275,7 @@ def get_linked_dunnings_as_per_state(sales_invoice, state): .join(overdue_payment) .on(overdue_payment.parent == dunning.name) .select(dunning.name) + .distinct() .where( (dunning.status == state) & (dunning.docstatus != 2) diff --git a/erpnext/accounts/doctype/dunning/test_dunning.py b/erpnext/accounts/doctype/dunning/test_dunning.py index 4508738a471..cb2559e75f4 100644 --- a/erpnext/accounts/doctype/dunning/test_dunning.py +++ b/erpnext/accounts/doctype/dunning/test_dunning.py @@ -123,6 +123,41 @@ class TestDunning(ERPNextTestSuite): self.assertEqual(sales_invoice.status, "Overdue") self.assertEqual(dunning.status, "Unresolved") + def test_payment_against_invoice_with_multiple_overdue_installments_in_dunning(self): + """ + When an invoice has more than one overdue installment, its Dunning holds one + Overdue Payment row per installment. Submitting a Payment Entry for the invoice + must resolve the Dunning without raising a TimestampMismatchError caused by the + same Dunning being loaded and saved more than once. + """ + create_payment_terms_template_for_dunning() + # Post far enough in the past that BOTH installments (5 and 10 credit days) are overdue. + sales_invoice = create_sales_invoice_against_cost_center( + posting_date=add_days(today(), -15), + qty=1, + rate=100, + do_not_submit=True, + ) + sales_invoice.payment_terms_template = "_Test 50-50 for Dunning" + sales_invoice.submit() + + dunning = create_dunning_from_sales_invoice(sales_invoice.name) + # Two overdue installments -> two overdue payment rows for the same invoice. + self.assertEqual(len(dunning.overdue_payments), 2) + dunning.submit() + self.assertEqual(dunning.status, "Unresolved") + + # Pay the invoice in full. This previously raised TimestampMismatchError on the Dunning. + pe = get_payment_entry("Sales Invoice", sales_invoice.name) + pe.reference_no, pe.reference_date = "3", nowdate() + pe.insert() + pe.submit() + + sales_invoice.reload() + dunning.reload() + self.assertEqual(sales_invoice.outstanding_amount, 0) + self.assertEqual(dunning.status, "Resolved") + def test_dunning_resolution_from_credit_note(self): """ Test that dunning is resolved when a credit note is issued against the original invoice.