From d2920915d29e31dbbc84007ef8df11faba123038 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Mon, 17 Aug 2026 11:55:38 +0530 Subject: [PATCH 1/2] test: dunning must stay unpaid when only the invoiced sum is paid paying a dunned invoice without its interest and fee marks the dunning resolved, and the interest can no longer be collected. (cherry picked from commit fa89552d10ee84a9ca9835aeda026076bcf52a6b) --- .../accounts/doctype/dunning/test_dunning.py | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/erpnext/accounts/doctype/dunning/test_dunning.py b/erpnext/accounts/doctype/dunning/test_dunning.py index 47cd0afe5d3..b0c4a59d03a 100644 --- a/erpnext/accounts/doctype/dunning/test_dunning.py +++ b/erpnext/accounts/doctype/dunning/test_dunning.py @@ -12,6 +12,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( create_dunning as create_dunning_from_sales_invoice, ) from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import ( + create_sales_invoice, create_sales_invoice_against_cost_center, ) from erpnext.tests.utils import ERPNextTestSuite @@ -54,6 +55,123 @@ class TestDunning(ERPNextTestSuite): dunning.reload() self.assertEqual(dunning.status, "Resolved") + def test_dunning_not_resolved_by_payment_of_invoiced_sum_only(self): + """ + Regression for #58220: paying the invoice without the interest and fee must not + resolve the dunning, the interest is still owed and has to stay claimable. + """ + dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC") + dunning.submit() + sales_invoice = dunning.overdue_payments[0].sales_invoice + + pe = get_payment_entry("Sales Invoice", sales_invoice) + pe.reference_no, pe.reference_date = "4", nowdate() + pe.insert() + pe.submit() + + self.assertEqual(frappe.get_value("Sales Invoice", sales_invoice, "outstanding_amount"), 0) + + dunning.reload() + self.assertEqual(dunning.status, "Unresolved") + self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41) + + # the interest and fee can still be collected on their own + pe = get_payment_entry("Dunning", dunning.name) + pe.reference_no, pe.reference_date = "5", nowdate() + self.assertEqual(pe.references, []) + self.assertEqual(round(pe.paid_amount, 2), 10.41) + pe.insert() + pe.submit() + + dunning.reload() + self.assertEqual(dunning.status, "Resolved") + self.assertEqual(dunning.get_unpaid_dunning_amount(), 0) + + # cancelling the interest payment makes the dunning claimable again + pe.cancel() + dunning.reload() + self.assertEqual(dunning.status, "Unresolved") + self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41) + + def test_dunning_can_be_cancelled_after_its_interest_was_paid(self): + """ + The payment collecting the interest links back to the dunning, which must not stand in + the way of cancelling it. + """ + dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC") + dunning.submit() + + pe = get_payment_entry("Dunning", dunning.name) + pe.reference_no, pe.reference_date = "6", nowdate() + pe.insert() + pe.submit() + + dunning.reload() + self.assertEqual(dunning.status, "Resolved") + + dunning.cancel() + self.assertEqual(dunning.docstatus, 2) + + def test_waived_interest_keeps_a_manually_resolved_dunning_resolved(self): + """ + Resolving a dunning by hand waives its interest, so a later payment of the invoice + must not reopen it. + """ + dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC") + dunning.submit() + sales_invoice = dunning.overdue_payments[0].sales_invoice + + # what the "Resolve" button does + dunning.reload() + dunning.status = "Resolved" + dunning.save() + + pe = get_payment_entry("Sales Invoice", sales_invoice) + pe.reference_no, pe.reference_date = "7", nowdate() + pe.insert() + pe.submit() + + dunning.reload() + self.assertEqual(dunning.status, "Resolved") + self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41) + + def test_unpaid_dunning_amount_is_tracked_in_company_currency(self): + """ + The interest and fee are collected as a Payment Entry deduction, a company currency + field, so what is left to collect has to be measured in the same currency. + """ + si = create_sales_invoice( + posting_date=add_days(today(), -15), + customer="_Test Customer USD", + currency="USD", + conversion_rate=50, + rate=100, + debit_to="_Test Receivable USD - _TC", + ) + + dunning = create_dunning_from_sales_invoice(si.name) + dunning_type = frappe.get_doc("Dunning Type", "Second Notice - _TC") + dunning.dunning_type = dunning_type.name + dunning.rate_of_interest = dunning_type.rate_of_interest + dunning.dunning_fee = dunning_type.dunning_fee + dunning.income_account = dunning_type.income_account + dunning.cost_center = dunning_type.cost_center + dunning.save() + + self.assertEqual(dunning.currency, "USD") + self.assertEqual(dunning.conversion_rate, 50) + self.assertEqual(round(dunning.dunning_amount, 2), 10.41) + self.assertEqual(round(dunning.base_dunning_amount, 2), 520.55) + + # nothing collected yet, in either currency + self.assertEqual(round(dunning.get_unpaid_base_dunning_amount(), 2), 520.55) + self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41) + + # the deduction booking the interest is in company currency + dunning.submit() + pe = get_payment_entry("Dunning", dunning.name) + self.assertEqual(round(pe.deductions[0].amount, 2), -520.55) + def test_fetch_overdue_payments(self): """ Create SI with overdue payment. Check if overdue payment is fetched in Dunning. From 7723f5aaedf93418ca03e16720e8a467c6e275a3 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Mon, 17 Aug 2026 11:55:38 +0530 Subject: [PATCH 2/2] fix: keep a dunning claimable until its interest is paid too a dunning was resolved as soon as the invoiced sum was settled, because the status was derived from the invoice outstanding alone. paying an invoice without the interest and fee therefore closed the dunning and lost the interest: a fresh dunning finds nothing overdue to charge it on. the dunning amount is never a receivable, it only reaches the ledger as a negative deduction on a payment entry made from the dunning. link that row to the dunning so what has been collected is known, and resolve a dunning only once the invoiced sum and the dunning amount are both paid. a dunning resolved by hand keeps its status, so waiving the interest stays possible. the deduction is a company currency field, so book and measure the dunning amount through base_dunning_amount instead of the transaction currency one. an interest-only payment leaves every invoice outstanding untouched, so update the linked dunnings from the payment entry itself instead of relying on the outstanding amount to change. such a payment also has to be built from what is left to collect, not from the totals the dunning was raised with, which are stale by then. (cherry picked from commit d5a9d158f92db71fbe0045e4e22dc3b0a766ac3a) --- erpnext/accounts/doctype/dunning/dunning.py | 99 ++++++++++++++++++- .../doctype/payment_entry/payment_entry.py | 60 ++++++----- .../payment_entry_deduction.json | 14 ++- .../payment_entry_deduction.py | 1 + 4 files changed, 143 insertions(+), 31 deletions(-) diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py index 508294161a3..58491dca9bb 100644 --- a/erpnext/accounts/doctype/dunning/dunning.py +++ b/erpnext/accounts/doctype/dunning/dunning.py @@ -17,7 +17,8 @@ import json import frappe from frappe import _ from frappe.contacts.doctype.address.address import get_address_display -from frappe.utils import getdate +from frappe.query_builder.functions import Sum +from frappe.utils import flt, getdate from erpnext.controllers.accounts_controller import AccountsController @@ -147,6 +148,31 @@ class Dunning(AccountsController): ) row.dunning_level = len(past_dunnings) + 1 + def get_unpaid_base_dunning_amount(self): + """Interest and dunning fee that is still to be collected, in company currency.""" + if not self.base_dunning_amount: + return 0.0 + + return flt( + flt(self.base_dunning_amount) - get_paid_dunning_amount(self.name), + self.precision("base_dunning_amount"), + ) + + def get_unpaid_dunning_amount(self): + """Interest and dunning fee that is still to be collected, in the dunning currency.""" + return flt( + self.get_unpaid_base_dunning_amount() / (flt(self.conversion_rate) or 1), + self.precision("dunning_amount"), + ) + + def get_unpaid_overdue_payments(self): + """Overdue payments with their outstanding as of now, not as of dunning creation.""" + return [ + (row, outstanding) + for row in self.overdue_payments + if (outstanding := get_current_outstanding(row)) > 0 + ] + def on_cancel(self): super().on_cancel() self.ignore_linked_doctypes = [ @@ -161,6 +187,7 @@ class Dunning(AccountsController): "Unreconcile Payment Entries", "Payment Ledger Entry", "Serial and Batch Bundle", + "Payment Entry", ] @frappe.whitelist() @@ -259,11 +286,73 @@ def update_linked_dunnings(doc, previous_outstanding_amount): if has_outstanding: break - new_status = "Resolved" if not has_outstanding else "Unresolved" + set_dunning_status(dunning, has_outstanding, respect_manual_resolution=True) - if dunning.status != new_status: - dunning.status = new_status - dunning.save() + +def update_dunnings_linked_to_payment(payment_entry): + """Refresh dunnings whose interest and fee are settled by this payment.""" + dunnings = {row.dunning for row in payment_entry.get("deductions") if row.dunning} + + for name in dunnings: + dunning = frappe.get_doc("Dunning", name) + if dunning.docstatus != 1: + continue + + set_dunning_status(dunning, bool(dunning.get_unpaid_overdue_payments())) + + +def set_dunning_status(dunning, has_outstanding_payments: bool, respect_manual_resolution: bool = False): + """A dunning is only resolved once the invoiced sum *and* its interest and fee are paid.""" + has_unpaid_dunning_amount = dunning.get_unpaid_dunning_amount() > 0 + new_status = "Unresolved" if has_outstanding_payments or has_unpaid_dunning_amount else "Resolved" + + # resolving by hand waives the interest, only an invoice that is owed again reopens it + if respect_manual_resolution and dunning.status == "Resolved" and not has_outstanding_payments: + return + + if dunning.status != new_status: + dunning.db_set("status", new_status, notify=True) + + +def get_paid_dunning_amount(dunning: str) -> float: + """Interest and fee collected for this dunning, in company currency.""" + deduction = frappe.qb.DocType("Payment Entry Deduction") + payment_entry = frappe.qb.DocType("Payment Entry") + + paid = ( + frappe.qb.from_(deduction) + .join(payment_entry) + .on(payment_entry.name == deduction.parent) + .select(Sum(deduction.amount)) + .where((deduction.dunning == dunning) & (payment_entry.docstatus == 1)) + ).run() + + # the dunning amount is booked as a negative deduction, against the income account + return -flt(paid[0][0]) if paid else 0.0 + + +def get_current_outstanding(overdue_payment) -> float: + """Outstanding of an overdue payment as of now, in the invoice's transaction currency.""" + invoice = frappe.db.get_value( + "Sales Invoice", + overdue_payment.sales_invoice, + ["outstanding_amount", "currency", "party_account_currency"], + as_dict=True, + ) + schedule_outstanding = ( + flt(frappe.db.get_value("Payment Schedule", overdue_payment.payment_schedule, "outstanding")) + if overdue_payment.payment_schedule + else flt(overdue_payment.outstanding) + ) + + if flt(invoice.outstanding_amount) <= 0 or schedule_outstanding <= 0: + return 0.0 + + outstanding = min(schedule_outstanding, flt(overdue_payment.outstanding)) + if invoice.currency == invoice.party_account_currency: + outstanding = min(outstanding, flt(invoice.outstanding_amount)) + + return outstanding def get_linked_dunnings_as_per_state(sales_invoice, state): diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 1df817be668..84f5b40b51a 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -3,6 +3,7 @@ import json +from datetime import date from functools import reduce import frappe @@ -208,9 +209,15 @@ class PaymentEntry(AccountsController): self.update_payment_schedule() self.make_gl_entries() self.update_outstanding_amounts() + self.update_linked_dunnings() self.set_status() self.trigger_invoice_update_for_subscriptions() + def update_linked_dunnings(self): + from erpnext.accounts.doctype.dunning.dunning import update_dunnings_linked_to_payment + + update_dunnings_linked_to_payment(self) + def validate_for_repost(self): validate_docs_for_voucher_types(["Payment Entry"]) validate_docs_for_deferred_accounting([self.name], []) @@ -314,6 +321,7 @@ class PaymentEntry(AccountsController): self.update_payment_schedule(cancel=1) self.make_gl_entries(cancel=1) self.update_outstanding_amounts() + self.update_linked_dunnings() self.delink_advance_entry_references() self.set_status() self.trigger_invoice_update_for_subscriptions() @@ -2888,15 +2896,15 @@ def get_reference_details( @frappe.whitelist() def get_payment_entry( - dt, - dn, - party_amount=None, - bank_account=None, - bank_amount=None, - party_type=None, - payment_type=None, - reference_date=None, - created_from_payment_request=False, + dt: str, + dn: str, + party_amount: int | float | None = None, + bank_account: str | None = None, + bank_amount: int | float | None = None, + party_type: str | None = None, + payment_type: str | None = None, + reference_date: str | date | None = None, + created_from_payment_request: bool | None = False, ): frappe.has_permission("Payment Entry", ptype="create", throw=True) @@ -2997,7 +3005,7 @@ def get_payment_entry( pe.append("references", reference) else: if dt == "Dunning": - for overdue_payment in doc.overdue_payments: + for overdue_payment, outstanding in doc.get_unpaid_overdue_payments(): pe.append( "references", { @@ -3005,21 +3013,23 @@ def get_payment_entry( "reference_name": overdue_payment.sales_invoice, "payment_term": overdue_payment.payment_term, "due_date": overdue_payment.due_date, - "total_amount": overdue_payment.outstanding, - "outstanding_amount": overdue_payment.outstanding, - "allocated_amount": overdue_payment.outstanding, + "total_amount": outstanding, + "outstanding_amount": outstanding, + "allocated_amount": outstanding, }, ) - pe.append( - "deductions", - { - "account": doc.income_account, - "cost_center": doc.cost_center, - "amount": -1 * doc.dunning_amount, - "description": _("Interest and/or dunning fee"), - }, - ) + if (unpaid_dunning_amount := doc.get_unpaid_base_dunning_amount()) > 0: + pe.append( + "deductions", + { + "account": doc.income_account, + "cost_center": doc.cost_center, + "amount": -1 * unpaid_dunning_amount, + "description": _("Interest and/or dunning fee"), + "dunning": doc.name, + }, + ) else: pe.append( "references", @@ -3309,8 +3319,10 @@ def set_grand_total_and_outstanding_amount(party_amount, dt, party_account_curre grand_total = doc.rounded_total or doc.grand_total outstanding_amount = doc.outstanding_amount elif dt == "Dunning": - grand_total = doc.grand_total - outstanding_amount = doc.grand_total + # only what is left to collect, the totals on the dunning are the ones it was raised with + grand_total = sum(outstanding for _row, outstanding in doc.get_unpaid_overdue_payments()) + grand_total += doc.get_unpaid_dunning_amount() + outstanding_amount = grand_total else: if party_account_currency == doc.company_currency: grand_total = flt(doc.get("base_rounded_total") or doc.get("base_grand_total")) diff --git a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json index 8d4e55c970a..7ed79a56cbc 100644 --- a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json +++ b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json @@ -10,7 +10,8 @@ "amount", "column_break_2", "is_exchange_gain_loss", - "description" + "description", + "dunning" ], "fields": [ { @@ -55,12 +56,21 @@ "fieldtype": "Check", "label": "Is Exchange Gain / Loss?", "read_only": 1 + }, + { + "fieldname": "dunning", + "fieldtype": "Link", + "label": "Dunning", + "no_copy": 1, + "options": "Dunning", + "print_hide": 1, + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-03-11 14:26:11.312950", + "modified": "2026-08-17 11:20:35.482913", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Entry Deduction", diff --git a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.py b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.py index ae4134fc27a..af29bcda4a8 100644 --- a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.py +++ b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.py @@ -18,6 +18,7 @@ class PaymentEntryDeduction(Document): amount: DF.Currency cost_center: DF.Link description: DF.SmallText | None + dunning: DF.Link | None is_exchange_gain_loss: DF.Check parent: DF.Data parentfield: DF.Data