mirror of
https://github.com/frappe/erpnext.git
synced 2026-10-29 22:57:42 +00:00
fix(accounts): validate dunning payments and lifecycle
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user