mirror of
https://github.com/frappe/erpnext.git
synced 2026-10-29 22:57:42 +00:00
Merge pull request #62551 from aerele/fix/dunning-payment-currency
fix(accounts): use receivable currency for dunning payments
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user