mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-17 10:36:31 +00:00
Merge pull request #58908 from frappe/mergify/bp/version-16-hotfix/pr-58227
fix: keep a dunning claimable until its interest is paid too (backport #58227)
This commit is contained in:
@@ -17,7 +17,8 @@ import json
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.contacts.doctype.address.address import get_address_display
|
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
|
from erpnext.controllers.accounts_controller import AccountsController
|
||||||
|
|
||||||
@@ -147,6 +148,31 @@ class Dunning(AccountsController):
|
|||||||
)
|
)
|
||||||
row.dunning_level = len(past_dunnings) + 1
|
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):
|
def on_cancel(self):
|
||||||
super().on_cancel()
|
super().on_cancel()
|
||||||
self.ignore_linked_doctypes = [
|
self.ignore_linked_doctypes = [
|
||||||
@@ -161,6 +187,7 @@ class Dunning(AccountsController):
|
|||||||
"Unreconcile Payment Entries",
|
"Unreconcile Payment Entries",
|
||||||
"Payment Ledger Entry",
|
"Payment Ledger Entry",
|
||||||
"Serial and Batch Bundle",
|
"Serial and Batch Bundle",
|
||||||
|
"Payment Entry",
|
||||||
]
|
]
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@@ -259,11 +286,73 @@ def update_linked_dunnings(doc, previous_outstanding_amount):
|
|||||||
if has_outstanding:
|
if has_outstanding:
|
||||||
break
|
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
|
def update_dunnings_linked_to_payment(payment_entry):
|
||||||
dunning.save()
|
"""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):
|
def get_linked_dunnings_as_per_state(sales_invoice, state):
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
|
|||||||
create_dunning as create_dunning_from_sales_invoice,
|
create_dunning as create_dunning_from_sales_invoice,
|
||||||
)
|
)
|
||||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import (
|
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import (
|
||||||
|
create_sales_invoice,
|
||||||
create_sales_invoice_against_cost_center,
|
create_sales_invoice_against_cost_center,
|
||||||
)
|
)
|
||||||
from erpnext.tests.utils import ERPNextTestSuite
|
from erpnext.tests.utils import ERPNextTestSuite
|
||||||
@@ -54,6 +55,123 @@ class TestDunning(ERPNextTestSuite):
|
|||||||
dunning.reload()
|
dunning.reload()
|
||||||
self.assertEqual(dunning.status, "Resolved")
|
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):
|
def test_fetch_overdue_payments(self):
|
||||||
"""
|
"""
|
||||||
Create SI with overdue payment. Check if overdue payment is fetched in Dunning.
|
Create SI with overdue payment. Check if overdue payment is fetched in Dunning.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import date
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
@@ -208,9 +209,15 @@ class PaymentEntry(AccountsController):
|
|||||||
self.update_payment_schedule()
|
self.update_payment_schedule()
|
||||||
self.make_gl_entries()
|
self.make_gl_entries()
|
||||||
self.update_outstanding_amounts()
|
self.update_outstanding_amounts()
|
||||||
|
self.update_linked_dunnings()
|
||||||
self.set_status()
|
self.set_status()
|
||||||
self.trigger_invoice_update_for_subscriptions()
|
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):
|
def validate_for_repost(self):
|
||||||
validate_docs_for_voucher_types(["Payment Entry"])
|
validate_docs_for_voucher_types(["Payment Entry"])
|
||||||
validate_docs_for_deferred_accounting([self.name], [])
|
validate_docs_for_deferred_accounting([self.name], [])
|
||||||
@@ -314,6 +321,7 @@ class PaymentEntry(AccountsController):
|
|||||||
self.update_payment_schedule(cancel=1)
|
self.update_payment_schedule(cancel=1)
|
||||||
self.make_gl_entries(cancel=1)
|
self.make_gl_entries(cancel=1)
|
||||||
self.update_outstanding_amounts()
|
self.update_outstanding_amounts()
|
||||||
|
self.update_linked_dunnings()
|
||||||
self.delink_advance_entry_references()
|
self.delink_advance_entry_references()
|
||||||
self.set_status()
|
self.set_status()
|
||||||
self.trigger_invoice_update_for_subscriptions()
|
self.trigger_invoice_update_for_subscriptions()
|
||||||
@@ -2888,15 +2896,15 @@ def get_reference_details(
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_payment_entry(
|
def get_payment_entry(
|
||||||
dt,
|
dt: str,
|
||||||
dn,
|
dn: str,
|
||||||
party_amount=None,
|
party_amount: int | float | None = None,
|
||||||
bank_account=None,
|
bank_account: str | None = None,
|
||||||
bank_amount=None,
|
bank_amount: int | float | None = None,
|
||||||
party_type=None,
|
party_type: str | None = None,
|
||||||
payment_type=None,
|
payment_type: str | None = None,
|
||||||
reference_date=None,
|
reference_date: str | date | None = None,
|
||||||
created_from_payment_request=False,
|
created_from_payment_request: bool | None = False,
|
||||||
):
|
):
|
||||||
frappe.has_permission("Payment Entry", ptype="create", throw=True)
|
frappe.has_permission("Payment Entry", ptype="create", throw=True)
|
||||||
|
|
||||||
@@ -2997,7 +3005,7 @@ def get_payment_entry(
|
|||||||
pe.append("references", reference)
|
pe.append("references", reference)
|
||||||
else:
|
else:
|
||||||
if dt == "Dunning":
|
if dt == "Dunning":
|
||||||
for overdue_payment in doc.overdue_payments:
|
for overdue_payment, outstanding in doc.get_unpaid_overdue_payments():
|
||||||
pe.append(
|
pe.append(
|
||||||
"references",
|
"references",
|
||||||
{
|
{
|
||||||
@@ -3005,21 +3013,23 @@ def get_payment_entry(
|
|||||||
"reference_name": overdue_payment.sales_invoice,
|
"reference_name": overdue_payment.sales_invoice,
|
||||||
"payment_term": overdue_payment.payment_term,
|
"payment_term": overdue_payment.payment_term,
|
||||||
"due_date": overdue_payment.due_date,
|
"due_date": overdue_payment.due_date,
|
||||||
"total_amount": overdue_payment.outstanding,
|
"total_amount": outstanding,
|
||||||
"outstanding_amount": overdue_payment.outstanding,
|
"outstanding_amount": outstanding,
|
||||||
"allocated_amount": overdue_payment.outstanding,
|
"allocated_amount": outstanding,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
pe.append(
|
if (unpaid_dunning_amount := doc.get_unpaid_base_dunning_amount()) > 0:
|
||||||
"deductions",
|
pe.append(
|
||||||
{
|
"deductions",
|
||||||
"account": doc.income_account,
|
{
|
||||||
"cost_center": doc.cost_center,
|
"account": doc.income_account,
|
||||||
"amount": -1 * doc.dunning_amount,
|
"cost_center": doc.cost_center,
|
||||||
"description": _("Interest and/or dunning fee"),
|
"amount": -1 * unpaid_dunning_amount,
|
||||||
},
|
"description": _("Interest and/or dunning fee"),
|
||||||
)
|
"dunning": doc.name,
|
||||||
|
},
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
pe.append(
|
pe.append(
|
||||||
"references",
|
"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
|
grand_total = doc.rounded_total or doc.grand_total
|
||||||
outstanding_amount = doc.outstanding_amount
|
outstanding_amount = doc.outstanding_amount
|
||||||
elif dt == "Dunning":
|
elif dt == "Dunning":
|
||||||
grand_total = doc.grand_total
|
# only what is left to collect, the totals on the dunning are the ones it was raised with
|
||||||
outstanding_amount = doc.grand_total
|
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:
|
else:
|
||||||
if party_account_currency == doc.company_currency:
|
if party_account_currency == doc.company_currency:
|
||||||
grand_total = flt(doc.get("base_rounded_total") or doc.get("base_grand_total"))
|
grand_total = flt(doc.get("base_rounded_total") or doc.get("base_grand_total"))
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"amount",
|
"amount",
|
||||||
"column_break_2",
|
"column_break_2",
|
||||||
"is_exchange_gain_loss",
|
"is_exchange_gain_loss",
|
||||||
"description"
|
"description",
|
||||||
|
"dunning"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@@ -55,12 +56,21 @@
|
|||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Is Exchange Gain / Loss?",
|
"label": "Is Exchange Gain / Loss?",
|
||||||
"read_only": 1
|
"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,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-03-11 14:26:11.312950",
|
"modified": "2026-08-17 11:20:35.482913",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Payment Entry Deduction",
|
"name": "Payment Entry Deduction",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class PaymentEntryDeduction(Document):
|
|||||||
amount: DF.Currency
|
amount: DF.Currency
|
||||||
cost_center: DF.Link
|
cost_center: DF.Link
|
||||||
description: DF.SmallText | None
|
description: DF.SmallText | None
|
||||||
|
dunning: DF.Link | None
|
||||||
is_exchange_gain_loss: DF.Check
|
is_exchange_gain_loss: DF.Check
|
||||||
parent: DF.Data
|
parent: DF.Data
|
||||||
parentfield: DF.Data
|
parentfield: DF.Data
|
||||||
|
|||||||
Reference in New Issue
Block a user