feat(accounts): split exchange gain and exchange loss accounts (#57839)

* feat(accounts): split exchange gain and exchange loss accounts

Add optional Exchange Gain Account and Exchange Loss Account fields on
Company. When set, realized FX gain/loss from settling an invoice in a
foreign currency (via Payment Entry, Payment Reconciliation, or a
Journal-Entry-based advance) books to the matching account instead of
the single Exchange Gain/Loss account. Either field left blank falls
back to the existing Exchange Gain/Loss account, so companies that
don't configure the new fields are unaffected.

New companies get "Exchange Gain" and "Exchange Loss" accounts
auto-created in their chart of accounts and auto-assigned to the new
fields, same as the existing Exchange Gain/Loss account provisioning.

The Payment Reconciliation tool's per-allocation "Difference Account"
override in its reconcile dialog continues to work as before; the
split accounts only change the computed default shown there.

* test(account_balance): account for new Exchange Gain account in income report

The new auto-provisioned Exchange Gain account under Indirect Income
now shows up in the Income root type report for _Test Company 2.

---------

Co-authored-by: test <test@test.com>
This commit is contained in:
Jatin3128
2026-08-06 17:36:43 +05:30
committed by GitHub
parent a49fcfe888
commit 96a6db7387
13 changed files with 264 additions and 10 deletions

View File

@@ -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",

View File

@@ -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
},

View File

@@ -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",

View File

@@ -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",
},

View File

@@ -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

View File

@@ -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"):

View File

@@ -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(

View File

@@ -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",

View File

@@ -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,

View File

@@ -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"),
}

View File

@@ -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"]] },

View File

@@ -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",

View File

@@ -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",