From cecd07bbf43451415fb971ad13ab3b305d39aa87 Mon Sep 17 00:00:00 2001 From: NaviN <118178330+Navin-S-R@users.noreply.github.com> Date: Sun, 11 Jan 2026 18:57:39 +0530 Subject: [PATCH] fix(payment reconciliation): handle adhoc payment returns (#51311) * fix(payment reconciliation): handle reverse payments * test: validate payment return gain or loss * chore: typo --- .../payment_reconciliation.py | 42 +++- .../test_payment_reconciliation.py | 204 ++++++++++++++++++ 2 files changed, 241 insertions(+), 5 deletions(-) diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index e03db121473..b574941721f 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -6,7 +6,7 @@ import frappe from frappe import _, msgprint, qb from frappe.model.document import Document from frappe.model.meta import get_field_precision -from frappe.query_builder import Criterion +from frappe.query_builder import Case, Criterion from frappe.query_builder.custom import ConstantColumn from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today @@ -393,6 +393,9 @@ class PaymentReconciliation(Document): inv.outstanding_amount = flt(entry.get("outstanding_amount")) def get_difference_amount(self, payment_entry, invoice, allocated_amount): + party_account_defaults = frappe.get_cached_value( + "Account", self.receivable_payable_account, ["account_type", "account_currency"], as_dict=True + ) allocated_amount_precision = get_field_precision( frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount") ) @@ -400,9 +403,9 @@ class PaymentReconciliation(Document): frappe.get_meta("Payment Reconciliation Allocation").get_field("difference_amount") ) difference_amount = 0 - if frappe.get_cached_value( - "Account", self.receivable_payable_account, "account_currency" - ) != frappe.get_cached_value("Company", self.company, "default_currency"): + if party_account_defaults.get("account_currency") != frappe.get_cached_value( + "Company", self.company, "default_currency" + ): if invoice.get("exchange_rate") and payment_entry.get("exchange_rate", 1) != invoice.get( "exchange_rate", 1 ): @@ -414,7 +417,14 @@ class PaymentReconciliation(Document): invoice.get("exchange_rate", 1) * flt(allocated_amount, allocated_amount_precision), difference_amount_precision, ) - difference_amount = allocated_amount_in_ref_rate - allocated_amount_in_inv_rate + + # Added If clause to handle return Adhoc payments for account type holders ("Payable") + if party_account_defaults.get("account_type") in ("Payable") and invoice.get( + "invoice_type" + ) in ["Payment Entry", "Journal Entry"]: + difference_amount = allocated_amount_in_inv_rate - allocated_amount_in_ref_rate + else: + difference_amount = allocated_amount_in_ref_rate - allocated_amount_in_inv_rate return difference_amount @@ -677,6 +687,28 @@ class PaymentReconciliation(Document): ) invoice_exchange_map.update(journals_map) + payment_entries = [ + d.get("invoice_number") for d in invoices if d.get("invoice_type") == "Payment Entry" + ] + payment_entries.extend( + [d.get("reference_name") for d in payments if d.get("reference_type") == "Payment Entry"] + ) + if payment_entries: + pe = frappe.qb.DocType("Payment Entry") + query = ( + frappe.qb.from_(pe) + .select( + pe.name, + Case() + .when(pe.payment_type == "Receive", pe.source_exchange_rate) + .else_(pe.target_exchange_rate) + .as_("exchange_rate"), + ) + .where(pe.name.isin(payment_entries)) + ) + payment_entries = query.run(as_list=1) + invoice_exchange_map.update(payment_entries) + return invoice_exchange_map def validate_allocation(self): diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index b11f20ec90b..3682e7c63a9 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -2340,6 +2340,210 @@ class TestPaymentReconciliation(IntegrationTestCase): frappe.db.set_value("Company", self.company, default_settings) + def test_foreign_currency_reverse_payment_entry_against_payment_entry_for_customer(self): + transaction_date = nowdate() + customer = self.customer3 + amount = 1000 + exchange_rate_at_payment = 100 + exchange_rate_at_reverse_payment = 95 + + # Receive amount from customer - 1,00,000 + pe = self.create_payment_entry(amount=amount, posting_date=transaction_date, customer=customer) + pe.payment_type = "Receive" + pe.paid_from = self.debtors_eur + pe.paid_from_account_currency = "EUR" + pe.source_exchange_rate = exchange_rate_at_payment + pe.paid_amount = amount + pe.received_amount = exchange_rate_at_payment * amount + pe.paid_to = self.cash + pe.paid_to_account_currency = "INR" + pe = pe.save().submit() + + # Pay amount to customer - 95,000 + reverse_pe = self.create_payment_entry( + amount=amount, posting_date=transaction_date, customer=customer + ) + reverse_pe.payment_type = "Pay" + reverse_pe.paid_from = self.cash + reverse_pe.paid_from_account_currency = "INR" + reverse_pe.target_exchange_rate = exchange_rate_at_reverse_payment + reverse_pe.paid_amount = exchange_rate_at_reverse_payment * amount + reverse_pe.received_amount = amount + reverse_pe.paid_to = self.debtors_eur + reverse_pe.paid_to_account_currency = "EUR" + reverse_pe.save().submit() + + # Reconcile payments + pr = self.create_payment_reconciliation() + pr.party = customer + pr.receivable_payable_account = self.debtors_eur + pr.get_unreconciled_entries() + invoices = [invoice.as_dict() for invoice in pr.invoices] + payments = [payment.as_dict() for payment in pr.payments] + self.assertEqual(len(pr.get("invoices")), 1) + self.assertEqual(len(pr.get("payments")), 1) + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Check the difference_amount is a gain of 5000 + self.assertEqual(flt(pr.allocation[0].get("difference_amount")), 5000.0) + pr.reconcile() + + def test_foreign_currency_reverse_payment_entry_against_payment_entry_for_supplier(self): + transaction_date = nowdate() + self.supplier = "_Test Supplier USD" + amount = 1000 + exchange_rate_at_payment = 100 + exchange_rate_at_reverse_payment = 95 + + # Pay amount to supplier - 1,00,000 + pe = self.create_payment_entry(amount=amount, posting_date=transaction_date) + pe.payment_type = "Pay" + pe.party_type = "Supplier" + pe.party = self.supplier + pe.paid_from = self.cash + pe.paid_from_account_currency = "INR" + pe.target_exchange_rate = exchange_rate_at_payment + pe.paid_amount = exchange_rate_at_payment * amount + pe.received_amount = amount + pe.paid_to = self.creditors_usd + pe.paid_to_account_currency = "USD" + pe.save().submit() + + # Receive amount from supplier - 95,000 + reverse_pe = self.create_payment_entry(amount=amount, posting_date=transaction_date) + reverse_pe.payment_type = "Receive" + reverse_pe.party_type = "Supplier" + reverse_pe.party = self.supplier + reverse_pe.paid_from = self.creditors_usd + reverse_pe.paid_from_account_currency = "USD" + reverse_pe.source_exchange_rate = exchange_rate_at_reverse_payment + reverse_pe.paid_amount = amount + reverse_pe.received_amount = exchange_rate_at_reverse_payment * amount + reverse_pe.paid_to = self.cash + reverse_pe.paid_to_account_currency = "INR" + reverse_pe = reverse_pe.save().submit() + + # Reconcile payments + pr = self.create_payment_reconciliation(party_is_customer=False) + pr.party = self.supplier + pr.receivable_payable_account = self.creditors_usd + pr.get_unreconciled_entries() + invoices = [invoice.as_dict() for invoice in pr.invoices] + payments = [payment.as_dict() for payment in pr.payments] + + self.assertEqual(len(pr.get("invoices")), 1) + self.assertEqual(len(pr.get("payments")), 1) + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Check the difference_amount is a loss of 5000 + self.assertEqual(flt(pr.allocation[0].get("difference_amount")), -5000.0) + pr.reconcile() + + def test_foreign_currency_reverse_journal_entry_against_journal_entry_for_customer(self): + transaction_date = nowdate() + customer = self.customer3 + amount = 1000 + exchange_rate_at_payment = 95 + exchange_rate_at_reverse_payment = 100 + + # Receive amount from customer - 95,000 + je1 = self.create_journal_entry(self.cash, self.debtors_eur, amount, transaction_date) + je1.multi_currency = 1 + je1.accounts[0].exchange_rate = 1 + je1.accounts[0].debit_in_account_currency = exchange_rate_at_payment * amount + je1.accounts[0].debit = exchange_rate_at_payment * amount + je1.accounts[1].party_type = "Customer" + je1.accounts[1].party = customer + je1.accounts[1].exchange_rate = exchange_rate_at_payment + je1.accounts[1].credit_in_account_currency = amount + je1.accounts[1].credit = exchange_rate_at_payment * amount + je1.save() + je1.submit() + + # Pay amount to customer - 1,00,000 + je2 = self.create_journal_entry(self.debtors_eur, self.cash, amount, transaction_date) + je2.multi_currency = 1 + je2.accounts[0].party_type = "Customer" + je2.accounts[0].party = customer + je2.accounts[0].exchange_rate = exchange_rate_at_reverse_payment + je2.accounts[0].debit_in_account_currency = amount + je2.accounts[0].debit = exchange_rate_at_reverse_payment * amount + je2.accounts[1].exchange_rate = 1 + je2.accounts[1].credit_in_account_currency = exchange_rate_at_reverse_payment * amount + je2.accounts[1].credit = exchange_rate_at_reverse_payment * amount + je2.save() + je2.submit() + + # Reconcile payments + pr = self.create_payment_reconciliation() + pr.party = customer + pr.receivable_payable_account = self.debtors_eur + pr.get_unreconciled_entries() + + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 1) + + invoices = [invoice.as_dict() for invoice in pr.invoices] + payments = [payment.as_dict() for payment in pr.payments] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Check the difference_amount is a loss of 5000 + self.assertEqual(flt(pr.allocation[0].difference_amount), -5000.0) + pr.reconcile() + + def test_foreign_currency_reverse_journal_entry_against_journal_entry_for_supplier(self): + transaction_date = nowdate() + self.supplier = "_Test Supplier USD" + amount = 1000 + exchange_rate_at_payment = 95 + exchange_rate_at_reverse_payment = 100 + + # Pay amount to supplier - 95,000 + je1 = self.create_journal_entry(self.creditors_usd, self.cash, amount, transaction_date) + je1.multi_currency = 1 + je1.accounts[0].party_type = "Supplier" + je1.accounts[0].party = self.supplier + je1.accounts[0].exchange_rate = exchange_rate_at_payment + je1.accounts[0].debit_in_account_currency = amount + je1.accounts[0].debit = exchange_rate_at_payment * amount + je1.accounts[1].exchange_rate = 1 + je1.accounts[1].credit = exchange_rate_at_payment * amount + je1.accounts[1].credit_in_account_currency = exchange_rate_at_payment * amount + je1.save() + je1.submit() + + # Receive amount from supplier - 1,00,000 + je2 = self.create_journal_entry(self.cash, self.creditors_usd, amount, transaction_date) + je2.multi_currency = 1 + je2.accounts[0].exchange_rate = 1 + je2.accounts[0].debit = exchange_rate_at_reverse_payment * amount + je2.accounts[0].debit_in_account_currency = exchange_rate_at_reverse_payment * amount + je2.accounts[1].party_type = "Supplier" + je2.accounts[1].party = self.supplier + je2.accounts[1].exchange_rate = exchange_rate_at_reverse_payment + je2.accounts[1].credit_in_account_currency = amount + je2.accounts[1].credit = exchange_rate_at_reverse_payment * amount + je2.save() + je2.submit() + + # Reconcile payments + pr = self.create_payment_reconciliation() + pr.party_type = "Supplier" + pr.party = self.supplier + pr.receivable_payable_account = self.creditors_usd + pr.get_unreconciled_entries() + + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 1) + + invoices = [invoice.as_dict() for invoice in pr.invoices] + payments = [payment.as_dict() for payment in pr.payments] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Check the difference_amount is a gain of 5000 + self.assertEqual(flt(pr.allocation[0].difference_amount), 5000.0) + pr.reconcile() + def make_customer(customer_name, currency=None): if not frappe.db.exists("Customer", customer_name):