Merge pull request #62551 from aerele/fix/dunning-payment-currency

fix(accounts): use receivable currency for dunning payments
This commit is contained in:
mergify[bot]
2026-10-29 13:51:30 +00:00
committed by GitHub
3 changed files with 136 additions and 23 deletions
+19 -14
View File
@@ -323,12 +323,12 @@ class Dunning(AccountsController):
self.precision("dunning_amount"),
)
def get_unpaid_overdue_payments(self):
def get_unpaid_overdue_payments(self, in_account_currency=False):
"""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
if (outstanding := get_current_outstanding(row, in_account_currency=in_account_currency)) > 0
]
def before_cancel(self):
@@ -392,11 +392,13 @@ class Dunning(AccountsController):
self.language = letter_text.language if letter_text else self.language
def get_payment_schedule_outstanding(invoice, payment_schedule):
def get_payment_schedule_outstanding(invoice, payment_schedule, in_account_currency=False):
"""Apply payments without term allocations to the oldest installments first."""
outstanding = {row.name: flt(row.outstanding) for row in payment_schedule}
if invoice.party_account_currency != invoice.currency:
return outstanding
if not in_account_currency:
return outstanding
outstanding = {name: amount * flt(invoice.conversion_rate) for name, amount in outstanding.items()}
unallocated_payment = max(sum(outstanding.values()) - flt(invoice.outstanding_amount), 0)
for row in sorted(payment_schedule, key=lambda row: (getdate(row.due_date), row.idx)):
@@ -519,31 +521,34 @@ def get_paid_dunning_amount(dunning: str) -> float:
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."""
def get_current_outstanding(overdue_payment, in_account_currency=False) -> float:
"""Return the live outstanding in transaction currency, or in receivable-account currency."""
invoice = frappe.db.get_value(
"Sales Invoice",
overdue_payment.sales_invoice,
["outstanding_amount", "currency", "party_account_currency"],
["outstanding_amount", "currency", "party_account_currency", "conversion_rate"],
as_dict=True,
)
outstanding = flt(overdue_payment.outstanding)
if in_account_currency and invoice.currency != invoice.party_account_currency:
outstanding = flt(
outstanding * flt(invoice.conversion_rate), overdue_payment.precision("outstanding")
)
if overdue_payment.payment_schedule:
payment_schedule = frappe.get_all(
"Payment Schedule",
filters={"parent": overdue_payment.sales_invoice, "parenttype": "Sales Invoice"},
fields=["name", "idx", "due_date", "outstanding"],
)
schedule_outstanding = get_payment_schedule_outstanding(invoice, payment_schedule).get(
overdue_payment.payment_schedule, 0
schedule_outstanding = get_payment_schedule_outstanding(
invoice, payment_schedule, in_account_currency=in_account_currency
)
else:
schedule_outstanding = flt(overdue_payment.outstanding)
outstanding = min(outstanding, schedule_outstanding.get(overdue_payment.payment_schedule, 0))
if flt(invoice.outstanding_amount) <= 0 or schedule_outstanding <= 0:
if flt(invoice.outstanding_amount) <= 0 or outstanding <= 0:
return 0.0
outstanding = min(schedule_outstanding, flt(overdue_payment.outstanding))
if invoice.currency == invoice.party_account_currency:
if in_account_currency or invoice.currency == invoice.party_account_currency:
outstanding = min(outstanding, flt(invoice.outstanding_amount))
return outstanding
@@ -5,7 +5,7 @@ from unittest.mock import patch
import frappe
from frappe.model import mapper
from frappe.utils import add_days, nowdate, today
from frappe.utils import add_days, flt, nowdate, today
from erpnext import get_default_cost_center
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
@@ -562,6 +562,112 @@ class TestDunning(ERPNextTestSuite):
# Must reflect 100 USD, not 5000 INR mislabelled as USD
self.assertEqual(dunning.overdue_payments[0].outstanding, 100.0)
@ERPNextTestSuite.change_settings(
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
)
def test_payment_uses_receivable_currency(self):
create_payment_terms_template_for_dunning()
template = frappe.get_doc("Payment Terms Template", "_Test 50-50 for Dunning")
for bank_account, bank_rate, allocate_terms in (
("_Test Cash - _TC", 1, 1),
("_Test Bank USD - _TC", 80, 1),
("_Test Cash - _TC", 1, 0),
("_Test Bank USD - _TC", 80, 0),
):
template.allocate_payment_based_on_payment_terms = allocate_terms
template.save()
with (
self.subTest(bank_account=bank_account, allocate_terms=allocate_terms),
self.set_user("test@example.com"),
):
invoice = create_sales_invoice(
posting_date=add_days(today(), -15),
currency="USD",
conversion_rate=80,
qty=1,
rate=100,
debit_to="Debtors - _TC",
cost_center="_Test Cost Center - _TC",
do_not_save=True,
)
invoice.payment_terms_template = "_Test 50-50 for Dunning"
invoice.submit()
dunning = create_dunning_from_sales_invoice(invoice.name)
dunning.conversion_rate = 90
dunning.dunning_fee = 10
dunning.rate_of_interest = 0
dunning.income_account = get_income_account(invoice.company)
dunning.cost_center = invoice.cost_center
dunning.submit()
self.assertEqual([amount for row, amount in dunning.get_unpaid_overdue_payments()], [50, 50])
self.assertEqual(
[amount for row, amount in dunning.get_unpaid_overdue_payments(in_account_currency=True)],
[4000, 4000],
)
for outstanding, allocations, schedule_outstanding in (
(8000, [4000, 4000], [0, 0]),
(4000, [4000], [0, 0]) if allocate_terms else (3200, [3200], [50, 10]),
):
payment = get_payment_entry(
"Dunning",
dunning.name,
bank_account=bank_account,
bank_amount=(outstanding + 900) / bank_rate,
)
payment.target_exchange_rate = bank_rate
payment.reference_no = "Dunning settlement"
self.assertEqual(payment.paid_amount, outstanding + 900)
self.assertEqual(payment.received_amount, (outstanding + 900) / bank_rate)
self.assertEqual([row.allocated_amount for row in payment.references], allocations)
self.assertEqual(payment.deductions[0].amount, -900)
payment.submit()
self.assertEqual(invoice.reload().outstanding_amount, 0)
self.assertEqual(
[row.outstanding for row in invoice.payment_schedule], schedule_outstanding
)
self.assertEqual(dunning.reload().status, "Resolved")
self.assertEqual(dunning.get_unpaid_dunning_amount(), 0)
gl_entries = frappe.get_all(
"GL Entry",
filters={
"voucher_type": payment.doctype,
"voucher_no": payment.name,
"is_cancelled": 0,
},
fields=["debit", "credit"],
)
self.assertEqual(flt(sum(row.debit for row in gl_entries), 2), outstanding + 900)
self.assertEqual(flt(sum(row.credit for row in gl_entries), 2), outstanding + 900)
payment_ledger_entries = frappe.get_all(
"Payment Ledger Entry",
filters={
"against_voucher_type": invoice.doctype,
"against_voucher_no": invoice.name,
"delinked": 0,
},
pluck="amount_in_account_currency",
)
self.assertEqual(flt(sum(payment_ledger_entries), 2), 0)
payment.reload().cancel()
self.assertEqual(invoice.reload().outstanding_amount, outstanding)
self.assertEqual(dunning.reload().status, "Unresolved")
self.assertEqual(dunning.get_unpaid_dunning_amount(), 10)
if outstanding == 8000:
partial_payment = get_payment_entry(
invoice.doctype, invoice.name, bank_account="_Test Cash - _TC"
)
partial_payment.paid_amount = partial_payment.received_amount = (
4000 if allocate_terms else 4800
)
partial_payment.references[0].allocated_amount = partial_payment.paid_amount
if allocate_terms:
partial_payment.references[1].allocated_amount = 0
partial_payment.submit()
partial_payment.cancel()
self.assertEqual(invoice.reload().outstanding_amount, 8000)
self.assertEqual([row.outstanding for row in invoice.payment_schedule], [50, 50])
def test_dunning_not_affected_by_standalone_credit_note(self):
"""
Test that dunning is NOT resolved when a credit note has update_outstanding_for_self checked.
@@ -1023,11 +1023,7 @@ class PaymentEntry(AccountsController):
ref_currency, ref_exchange_rate = frappe.db.get_value(
reference_doctype, reference_docname, ["currency", "conversion_rate"]
)
is_single_currency = self.paid_from_account_currency == self.paid_to_account_currency
# PE in different currency
reference_is_multi_currency = self.paid_from_account_currency != ref_currency
if not (is_single_currency and reference_is_multi_currency):
if self.party_account_currency == ref_currency:
return allocated_amount
allocated_amount = flt(allocated_amount / ref_exchange_rate, self.precision("total_allocated_amount"))
@@ -2835,7 +2831,7 @@ def get_payment_entry(
pe.append("references", reference)
else:
if dt == "Dunning":
for overdue_payment, outstanding in doc.get_unpaid_overdue_payments():
for overdue_payment, outstanding in doc.get_unpaid_overdue_payments(in_account_currency=True):
pe.append(
"references",
{
@@ -3153,8 +3149,14 @@ def set_grand_total_and_outstanding_amount(party_amount, dt, party_account_curre
outstanding_amount = doc.outstanding_amount
elif dt == "Dunning":
# 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()
grand_total = sum(
outstanding for _row, outstanding in doc.get_unpaid_overdue_payments(in_account_currency=True)
)
grand_total += (
doc.get_unpaid_dunning_amount()
if party_account_currency == doc.currency
else doc.get_unpaid_base_dunning_amount()
)
outstanding_amount = grand_total
else:
if party_account_currency == doc.company_currency: