From 7723f5aaedf93418ca03e16720e8a467c6e275a3 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Mon, 17 Aug 2026 11:55:38 +0530 Subject: [PATCH] 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