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/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. 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