fix(accounts): validate dunning payments and lifecycle

This commit is contained in:
pandiyan
2026-10-26 21:27:42 +05:30
parent 72ef822ba3
commit bbfc103177
4 changed files with 100 additions and 5 deletions
@@ -231,7 +231,7 @@
},
{
"allow_on_submit": 1,
"default": "Unresolved",
"default": "Draft",
"fieldname": "status",
"fieldtype": "Select",
"in_standard_filter": 1,
@@ -396,7 +396,7 @@
],
"is_submittable": 1,
"links": [],
"modified": "2026-10-22 11:40:47.166878",
"modified": "2026-10-26 11:25:50.077318",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Dunning",
+47 -3
View File
@@ -21,6 +21,7 @@ from frappe.query_builder.functions import Sum
from frappe.utils import flt, getdate
from erpnext.controllers.accounts_controller import AccountsController
from erpnext.setup.utils import get_exchange_rate
class Dunning(AccountsController):
@@ -71,13 +72,41 @@ class Dunning(AccountsController):
# end: auto-generated types
def validate(self):
self.validate_currency()
self.set_dunning_fee()
self.validate_overdue_payment_references()
self.validate_overdue_payments()
self.validate_totals()
if self.docstatus == 0:
self.status = "Draft"
self.set_party_details()
self.set_dunning_level()
def validate_currency(self):
self.currency = self.currency or self.company_currency
if self.currency == self.company_currency:
self.conversion_rate = 1
elif not self.conversion_rate or (
self.get_doc_before_save()
and not self.has_value_changed("conversion_rate")
and any(self.has_value_changed(field) for field in ("currency", "company", "posting_date"))
):
self.conversion_rate = get_exchange_rate(
self.currency, self.company_currency, self.posting_date, "for_selling"
)
if flt(self.conversion_rate) <= 0:
frappe.throw(
_("Conversion Rate from {0} to {1} must be greater than zero.").format(
self.currency, self.company_currency
)
)
def before_submit(self):
if self.dunning_amount and not self.income_account:
frappe.throw(_("Income Account is required to collect dunning fees or interest."))
self.status = "Unresolved"
def set_dunning_fee(self):
if not self.dunning_type:
self.dunning_fee = flt(self.dunning_fee)
@@ -302,6 +331,10 @@ class Dunning(AccountsController):
if (outstanding := get_current_outstanding(row)) > 0
]
def before_cancel(self):
super().before_cancel()
self.status = "Cancelled"
def on_cancel(self):
super().on_cancel()
self.ignore_linked_doctypes = [
@@ -316,7 +349,6 @@ class Dunning(AccountsController):
"Unreconcile Payment Entries",
"Payment Ledger Entry",
"Serial and Batch Bundle",
"Payment Entry",
]
@frappe.whitelist()
@@ -462,13 +494,25 @@ 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")
dunning_doc = frappe.qb.DocType("Dunning")
paid = (
frappe.qb.from_(deduction)
.join(payment_entry)
.on(payment_entry.name == deduction.parent)
.join(dunning_doc)
.on(dunning_doc.name == deduction.dunning)
.select(Sum(deduction.amount))
.where((deduction.dunning == dunning) & (payment_entry.docstatus == 1))
.where(
(deduction.dunning == dunning)
& (payment_entry.docstatus == 1)
& (payment_entry.payment_type == "Receive")
& (payment_entry.party_type == "Customer")
& (payment_entry.party == dunning_doc.customer)
& (payment_entry.company == dunning_doc.company)
& (deduction.account == dunning_doc.income_account)
& (deduction.amount < 0)
)
).run()
# the dunning amount is booked as a negative deduction, against the income account
@@ -510,7 +554,7 @@ def get_linked_dunnings_as_per_state(sales_invoice, state):
.select(dunning.name)
.where(
(dunning.status == state)
& (dunning.docstatus != 2)
& (dunning.docstatus == 1)
& (overdue_payment.sales_invoice == sales_invoice)
)
).run(as_dict=True)
@@ -98,6 +98,7 @@ class PaymentEntry(AccountsController):
self.set_exchange_rate()
self.validate_mandatory()
self.validate_reference_documents()
self.validate_dunning_deductions()
self.set_amounts()
self.validate_amounts()
self.apply_taxes()
@@ -115,6 +116,54 @@ class PaymentEntry(AccountsController):
self.set_status()
self.set_total_in_words()
def validate_dunning_deductions(self):
dunning_names = {row.dunning for row in self.deductions if row.dunning}
if not dunning_names:
return
dunnings = {
row.name: row
for row in frappe.get_all(
"Dunning",
filters={"name": ("in", list(dunning_names))},
fields=["name", "docstatus", "customer", "company", "income_account"],
)
}
for row in self.deductions:
if not row.dunning:
continue
dunning = dunnings.get(row.dunning)
if not dunning or dunning.docstatus != 1:
frappe.throw(_("Row #{0}: Dunning {1} must be submitted.").format(row.idx, row.dunning))
if self.payment_type != "Receive" or self.party_type != "Customer":
frappe.throw(
_("Row #{0}: Dunning charges require a Receive payment from a Customer.").format(row.idx)
)
if self.party != dunning.customer:
frappe.throw(
_("Row #{0}: Dunning {1} belongs to a different customer.").format(row.idx, row.dunning)
)
if self.company != dunning.company:
frappe.throw(
_("Row #{0}: Dunning {1} belongs to a different company.").format(row.idx, row.dunning)
)
if row.account != dunning.income_account:
frappe.throw(
_(
"Row #{0}: Dunning charges must use the income account {1} configured on Dunning {2}."
).format(row.idx, dunning.income_account, row.dunning)
)
if flt(row.amount) >= 0:
frappe.throw(
_("Row #{0}: Dunning charges must have a negative deduction amount.").format(row.idx)
)
def before_save(self):
self.set_matched_unset_payment_requests_to_response()
+2
View File
@@ -461,3 +461,5 @@ execute:from erpnext.setup.install import grant_address_read_to_accounts_manager
erpnext.patches.v16_0.remove_invalid_fetch_from_italy_payment_schedule_swift_number
erpnext.patches.v16_0.set_user_on_timesheets
erpnext.patches.v16_0.scope_voice_call_settings_to_desk_users
execute:frappe.db.set_value("Dunning", {"docstatus": 0}, "status", "Draft", update_modified=False)
execute:frappe.db.set_value("Dunning", {"docstatus": 2}, "status", "Cancelled", update_modified=False)