diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py index 6ed89c22f24..e0d8f15a664 100644 --- a/erpnext/accounts/doctype/account/account.py +++ b/erpnext/accounts/doctype/account/account.py @@ -730,6 +730,8 @@ def get_company_default_account_fields(): "default_discount_account": "Default Payment Discount Account", "unrealized_profit_loss_account": "Unrealized Profit / Loss Account", "exchange_gain_loss_account": "Exchange Gain / Loss Account", + "exchange_gain_account": "Exchange Gain Account", + "exchange_loss_account": "Exchange Loss Account", "unrealized_exchange_gain_loss_account": "Unrealized Exchange Gain / Loss Account", "round_off_account": "Round Off Account", "default_deferred_revenue_account": "Default Deferred Revenue Account", diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/in_standard_chart_of_accounts.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/in_standard_chart_of_accounts.json index af0aca38c93..dde312ed04b 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/in_standard_chart_of_accounts.json +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/in_standard_chart_of_accounts.json @@ -179,6 +179,9 @@ }, "Impairment": { "account_category": "Operating Expenses" + }, + "Exchange Loss": { + "account_category": "Operating Expenses" } }, "root_type": "Expense" @@ -196,6 +199,10 @@ "account_type": "Income Account" }, "Indirect Income": { + "Exchange Gain": { + "account_type": "Income Account", + "account_category": "Other Operating Income" + }, "account_type": "Income Account", "is_group": 1 }, diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py b/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py index 7901cc90230..cb9be411b2a 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py @@ -138,6 +138,7 @@ def get(): _("Gain/Loss on Asset Disposal"): {"account_category": "Other Operating Income"}, _("Impairment"): {"account_category": "Operating Expenses"}, _("Tax Expense"): {"account_category": "Tax Expense"}, + _("Exchange Loss"): {"account_category": "Operating Expenses"}, }, "root_type": "Expense", }, @@ -149,6 +150,7 @@ def get(): _("Indirect Income"): { _("Interest Income"): {"account_category": "Investment Income"}, _("Interest on Fixed Deposits"): {"account_category": "Investment Income"}, + _("Exchange Gain"): {"account_category": "Other Operating Income"}, "is_group": 1, }, "root_type": "Income", diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py b/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py index e38369ceb1d..f2fe198bc65 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py @@ -233,6 +233,7 @@ def get(): }, _("Impairment"): {"account_number": "5224", "account_category": "Operating Expenses"}, _("Tax Expense"): {"account_number": "5225", "account_category": "Tax Expense"}, + _("Exchange Loss"): {"account_number": "5226", "account_category": "Operating Expenses"}, "account_number": "5200", }, "root_type": "Expense", @@ -250,6 +251,10 @@ def get(): "account_number": "4220", "account_category": "Investment Income", }, + _("Exchange Gain"): { + "account_number": "4230", + "account_category": "Other Operating Income", + }, "is_group": 1, "account_number": "4200", }, diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index d39adeaaf5e..879b03e342d 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -950,6 +950,61 @@ class TestPaymentEntry(ERPNextTestSuite): outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount")) self.assertEqual(outstanding_amount, 0) + def test_exchange_gain_loss_split_accounts(self): + gain_account = create_account( + account_name="_Test Exchange Gain", + parent_account="Indirect Expenses - _TC", + company="_Test Company", + ) + loss_account = create_account( + account_name="_Test Exchange Loss", + parent_account="Indirect Expenses - _TC", + company="_Test Company", + ) + frappe.db.set_value("Company", "_Test Company", "exchange_gain_account", gain_account) + frappe.db.set_value("Company", "_Test Company", "exchange_loss_account", loss_account) + self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "exchange_gain_account", "") + self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "exchange_loss_account", "") + + si_gain = create_sales_invoice( + customer="_Test Customer USD", + debit_to="_Test Receivable USD - _TC", + currency="USD", + conversion_rate=50, + ) + pe_gain = get_payment_entry("Sales Invoice", si_gain.name, bank_account="_Test Bank USD - _TC") + pe_gain.reference_no = "1" + pe_gain.reference_date = "2016-01-01" + pe_gain.source_exchange_rate = 55 + pe_gain.save() + self.assertEqual(pe_gain.references[0].exchange_gain_loss, 500) + pe_gain.submit() + + self.assertEqual(self.get_gain_loss_journal_account(pe_gain.name), gain_account) + + si_loss = create_sales_invoice( + customer="_Test Customer USD", + debit_to="_Test Receivable USD - _TC", + currency="USD", + conversion_rate=55, + ) + pe_loss = get_payment_entry("Sales Invoice", si_loss.name, bank_account="_Test Bank USD - _TC") + pe_loss.reference_no = "2" + pe_loss.reference_date = "2016-01-01" + pe_loss.source_exchange_rate = 50 + pe_loss.save() + self.assertEqual(pe_loss.references[0].exchange_gain_loss, -500) + pe_loss.submit() + + self.assertEqual(self.get_gain_loss_journal_account(pe_loss.name), loss_account) + + def get_gain_loss_journal_account(self, payment_entry_name: str) -> str | None: + return frappe.db.get_value( + "Journal Entry Account", + {"reference_type": "Payment Entry", "reference_name": payment_entry_name, "docstatus": 1}, + "account", + ) + def test_payment_entry_against_sales_invoice_with_cost_centre(self): from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index d3ce2a0a2f7..2554d4d653f 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -18,6 +18,7 @@ from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_rec is_any_doc_running, ) from erpnext.accounts.services.advances import get_advance_payment_entries_for_regional +from erpnext.accounts.services.exchange_gain_loss import get_exchange_gain_loss_account from erpnext.accounts.utils import ( QueryPaymentLedger, create_gain_loss_journal, @@ -485,9 +486,6 @@ class PaymentReconciliation(Document): "Accounts Settings", "exchange_gain_loss_posting_date", cache=True ) invoice_exchange_map = self.get_invoice_exchange_map(args.get("invoices"), args.get("payments")) - default_exchange_gain_loss_account = frappe.get_cached_value( - "Company", self.company, "exchange_gain_loss_account" - ) entries = [] for pay in args.get("payments"): @@ -507,7 +505,10 @@ class PaymentReconciliation(Document): pay["exchange_rate"] = invoice_exchange_map.get(pay.get("reference_name")) res.difference_amount = self.get_difference_amount(pay, inv, res["allocated_amount"]) - res.difference_account = default_exchange_gain_loss_account + is_gain = ( + res.difference_amount > 0 if self.party_type == "Customer" else res.difference_amount < 0 + ) + res.difference_account = get_exchange_gain_loss_account(self.company, is_gain) res.exchange_rate = inv.get("exchange_rate") res.update({"gain_loss_posting_date": pay.get("posting_date")}) if not pay.get("is_advance"): diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index 03d5c9cc791..bec6ab1e236 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -6,6 +6,7 @@ import frappe from frappe.utils import add_days, add_years, cint, flt, getdate, nowdate, today from frappe.utils.data import getdate as convert_to_date +from erpnext.accounts.doctype.account.test_account import create_account from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice @@ -187,6 +188,53 @@ class TestPaymentReconciliation(ERPNextTestSuite): ) return je + def setup_split_exchange_accounts(self): + gain_account = create_account( + account_name="_Test PR Split Exchange Gain", + parent_account="Indirect Expenses - _TC", + company=self.company, + ) + loss_account = create_account( + account_name="_Test PR Split Exchange Loss", + parent_account="Indirect Expenses - _TC", + company=self.company, + ) + frappe.db.set_value("Company", self.company, "exchange_gain_account", gain_account) + frappe.db.set_value("Company", self.company, "exchange_loss_account", loss_account) + self.addCleanup(frappe.db.set_value, "Company", self.company, "exchange_gain_account", "") + self.addCleanup(frappe.db.set_value, "Company", self.company, "exchange_loss_account", "") + return gain_account, loss_account + + def create_foreign_currency_sales_invoice(self, conversion_rate): + si = self.create_sales_invoice( + qty=1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True + ) + si.customer = self.customer_usd + si.currency = "USD" + si.conversion_rate = conversion_rate + si.debit_to = self.debtors_usd + si.save().submit() + return si + + def create_foreign_currency_journal_payment(self, debtors_account, exchange_rate): + je = self.create_journal_entry(self.bank, debtors_account, 100, nowdate()) + je.multi_currency = 1 + je.accounts[0].exchange_rate = 1 + je.accounts[0].credit_in_account_currency = 0 + je.accounts[0].credit = 0 + je.accounts[0].debit_in_account_currency = 100 * exchange_rate + je.accounts[0].debit = 100 * exchange_rate + je.accounts[1].party_type = "Customer" + je.accounts[1].party = self.customer_usd + je.accounts[1].exchange_rate = exchange_rate + je.accounts[1].credit_in_account_currency = 100 + je.accounts[1].credit = 100 * exchange_rate + je.accounts[1].debit_in_account_currency = 0 + je.accounts[1].debit = 0 + je.save() + je.submit() + return je + def test_voucher_outstanding_metadata_comes_from_one_ledger_entry(self): """cost_center and remarks must describe the same Payment Ledger Entry. @@ -956,6 +1004,85 @@ class TestPaymentReconciliation(ERPNextTestSuite): frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss" ) + def test_exchange_gain_loss_split_default_account(self): + gain_account, loss_account = self.setup_split_exchange_accounts() + + self.create_foreign_currency_sales_invoice(conversion_rate=80) + self.create_foreign_currency_journal_payment(self.debtors_usd, exchange_rate=85) + + pr = self.create_payment_reconciliation() + pr.party = self.customer_usd + pr.receivable_payable_account = self.debtors_usd + pr.get_unreconciled_entries() + + invoices = [x.as_dict() for x in pr.invoices] + payments = [x.as_dict() for x in pr.payments] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + self.assertEqual(pr.allocation[0].difference_amount, 500) + self.assertEqual(pr.allocation[0].difference_account, gain_account) + pr.reconcile() + + self.create_foreign_currency_sales_invoice(conversion_rate=85) + self.create_foreign_currency_journal_payment(self.debtors_usd, exchange_rate=80) + + pr = self.create_payment_reconciliation() + pr.party = self.customer_usd + pr.receivable_payable_account = self.debtors_usd + pr.get_unreconciled_entries() + + invoices = [x.as_dict() for x in pr.invoices] + payments = [x.as_dict() for x in pr.payments] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + self.assertEqual(pr.allocation[0].difference_amount, -500) + self.assertEqual(pr.allocation[0].difference_account, loss_account) + + def test_payment_reconciliation_difference_account_override(self): + _, loss_account = self.setup_split_exchange_accounts() + override_account = create_account( + account_name="_Test PR Override Exchange Account", + parent_account="Indirect Expenses - _TC", + company=self.company, + ) + + si = self.create_foreign_currency_sales_invoice(conversion_rate=85) + self.create_foreign_currency_journal_payment(self.debtors_usd, exchange_rate=80) + + pr = self.create_payment_reconciliation() + pr.party = self.customer_usd + pr.receivable_payable_account = self.debtors_usd + pr.get_unreconciled_entries() + + invoices = [x.as_dict() for x in pr.invoices] + payments = [x.as_dict() for x in pr.payments] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Default, computed from the split company fields, is pre-filled onto the row... + self.assertEqual(pr.allocation[0].difference_amount, -500) + self.assertEqual(pr.allocation[0].difference_account, loss_account) + + # ...but the user can override it in the "Select Difference Account" dialog before reconciling, + # and that explicit choice must be what actually gets booked, not the computed default. + pr.allocation[0].difference_account = override_account + pr.reconcile() + + jea_parent = frappe.db.get_all( + "Journal Entry Account", + filters={"account": self.debtors_usd, "docstatus": 1, "reference_name": si.name, "credit": 500}, + fields=["parent"], + )[0] + self.assertEqual( + frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss" + ) + + gain_loss_line_account = frappe.db.get_value( + "Journal Entry Account", + {"parent": jea_parent.parent, "account": ["!=", self.debtors_usd]}, + "account", + ) + self.assertEqual(gain_loss_line_account, override_account) + def test_difference_amount_via_negative_debit_or_credit_journal_entry(self): # Make Sale Invoice si = self.create_sales_invoice( diff --git a/erpnext/accounts/report/account_balance/test_account_balance.py b/erpnext/accounts/report/account_balance/test_account_balance.py index d83a26abea6..78f88b5d6c4 100644 --- a/erpnext/accounts/report/account_balance/test_account_balance.py +++ b/erpnext/accounts/report/account_balance/test_account_balance.py @@ -24,6 +24,11 @@ class TestAccountBalance(ERPNextTestSuite): "currency": "EUR", "balance": -100.0, }, + { + "account": "Exchange Gain - _TC2", + "currency": "EUR", + "balance": 0.0, + }, { "account": "Income - _TC2", "currency": "EUR", diff --git a/erpnext/accounts/services/exchange_gain_loss.py b/erpnext/accounts/services/exchange_gain_loss.py index a58a11105a1..df61e3e882a 100644 --- a/erpnext/accounts/services/exchange_gain_loss.py +++ b/erpnext/accounts/services/exchange_gain_loss.py @@ -11,6 +11,13 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import g from erpnext.accounts.utils import create_gain_loss_journal, get_currency_precision +def get_exchange_gain_loss_account(company: str, is_gain: bool) -> str | None: + fieldname = "exchange_gain_account" if is_gain else "exchange_loss_account" + return frappe.get_cached_value("Company", company, fieldname) or frappe.get_cached_value( + "Company", company, "exchange_gain_loss_account" + ) + + def gain_loss_journal_already_booked( gain_loss_account: str, exc_gain_loss: float, @@ -163,9 +170,7 @@ def make_exchange_gain_loss_journal( reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" - gain_loss_account = frappe.get_cached_value( - "Company", doc.company, "exchange_gain_loss_account" - ) + gain_loss_account = get_exchange_gain_loss_account(doc.company, reverse_dr_or_cr == "credit") je = create_gain_loss_journal( doc.company, args.get("difference_posting_date") if args else doc.posting_date, diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 70c5f3e77fe..035a7c514e2 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1039,9 +1039,16 @@ class AccountsController(TransactionBase): party_account = self.credit_to dr_or_cr = "debit_in_account_currency" + from erpnext.accounts.services.exchange_gain_loss import get_exchange_gain_loss_account + lst = [] for d in self.get("advances"): if flt(d.allocated_amount) > 0: + is_gain = ( + flt(d.get("exchange_gain_loss")) > 0 + if party_type == "Customer" + else flt(d.get("exchange_gain_loss")) < 0 + ) args = frappe._dict( { "voucher_type": d.reference_type, @@ -1068,9 +1075,7 @@ class AccountsController(TransactionBase): else self.grand_total ), "outstanding_amount": self.outstanding_amount, - "difference_account": frappe.get_cached_value( - "Company", self.company, "exchange_gain_loss_account" - ), + "difference_account": get_exchange_gain_loss_account(self.company, is_gain), "exchange_gain_loss": flt(d.get("exchange_gain_loss")), "difference_posting_date": d.get("difference_posting_date"), } diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js index 4dc23d4b1e6..f8c4205e574 100644 --- a/erpnext/setup/doctype/company/company.js +++ b/erpnext/setup/doctype/company/company.js @@ -309,6 +309,8 @@ erpnext.company.setup_queries = function (frm) { ["discount_allowed_account", { root_type: "Expense" }], ["discount_received_account", { root_type: "Income" }], ["exchange_gain_loss_account", { root_type: ["in", ["Expense", "Income"]] }], + ["exchange_gain_account", { root_type: ["in", ["Expense", "Income"]] }], + ["exchange_loss_account", { root_type: ["in", ["Expense", "Income"]] }], [ "unrealized_exchange_gain_loss_account", { root_type: ["in", ["Expense", "Income", "Equity", "Liability"]] }, diff --git a/erpnext/setup/doctype/company/company.json b/erpnext/setup/doctype/company/company.json index 84d1a161b87..5b85f1ff18e 100644 --- a/erpnext/setup/doctype/company/company.json +++ b/erpnext/setup/doctype/company/company.json @@ -65,6 +65,8 @@ "default_finance_book", "exchange_gain__loss_section", "exchange_gain_loss_account", + "exchange_gain_account", + "exchange_loss_account", "column_break_sttp", "unrealized_exchange_gain_loss_account", "round_off_section", @@ -397,6 +399,24 @@ "no_copy": 1, "options": "Account" }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "exchange_gain_account", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Exchange Gain Account", + "no_copy": 1, + "options": "Account" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "exchange_loss_account", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Exchange Loss Account", + "no_copy": 1, + "options": "Account" + }, { "depends_on": "eval:!doc.__islocal", "fieldname": "unrealized_exchange_gain_loss_account", diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 34022033aec..441751bf984 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -103,7 +103,9 @@ class Company(NestedSet): enable_provisional_accounting_for_non_stock_items: DF.Check enable_stock_delivered_but_not_billed: DF.Check exception_budget_approver_role: DF.Link | None + exchange_gain_account: DF.Link | None exchange_gain_loss_account: DF.Link | None + exchange_loss_account: DF.Link | None existing_company: DF.Link | None expenses_added_to_stock_account: DF.Link | None expenses_added_to_stock_contra_account: DF.Link | None @@ -369,6 +371,8 @@ class Company(NestedSet): ["Default Payment Discount Account", "default_discount_account"], ["Unrealized Profit / Loss Account", "unrealized_profit_loss_account"], ["Exchange Gain / Loss Account", "exchange_gain_loss_account"], + ["Exchange Gain Account", "exchange_gain_account"], + ["Exchange Loss Account", "exchange_loss_account"], ["Unrealized Exchange Gain / Loss Account", "unrealized_exchange_gain_loss_account"], ["Round Off Account", "round_off_account"], ["Default Deferred Revenue Account", "default_deferred_revenue_account"], @@ -792,6 +796,20 @@ class Company(NestedSet): self.db_set("exchange_gain_loss_account", exchange_gain_loss_acct) + if not self.exchange_gain_account: + exchange_gain_acct = frappe.db.get_value( + "Account", {"account_name": _("Exchange Gain"), "company": self.name, "is_group": 0} + ) + + self.db_set("exchange_gain_account", exchange_gain_acct) + + if not self.exchange_loss_account: + exchange_loss_acct = frappe.db.get_value( + "Account", {"account_name": _("Exchange Loss"), "company": self.name, "is_group": 0} + ) + + self.db_set("exchange_loss_account", exchange_loss_acct) + if not self.disposal_account: disposal_acct = frappe.db.get_value( "Account",