diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index e7a9fd690b9..3cf93f8bd14 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -46,6 +46,7 @@ "role_to_override_stop_action", "currency_exchange_section", "allow_stale", + "allow_pegged_currencies_exchange_rates", "column_break_yuug", "stale_days", "section_break_jpd0", @@ -614,6 +615,13 @@ { "fieldname": "column_break_feyo", "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "Enable this field to fetch the exchange rates for Pegged Currencies.\n\n", + "fieldname": "allow_pegged_currencies_exchange_rates", + "fieldtype": "Check", + "label": "Allow Pegged Currencies Exchange Rates" } ], "grid_page_length": 50, @@ -622,7 +630,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-06-06 11:03:28.095723", + "modified": "2025-06-16 16:40:54.871486", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py index ca3efd0a358..c37189a359c 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -26,6 +26,7 @@ class AccountsSettings(Document): acc_frozen_upto: DF.Date | None add_taxes_from_item_tax_template: DF.Check allow_multi_currency_invoices_against_single_party_account: DF.Check + allow_pegged_currencies_exchange_rates: DF.Check allow_stale: DF.Check auto_reconcile_payments: DF.Check auto_reconciliation_job_trigger: DF.Int diff --git a/erpnext/accounts/doctype/pegged_currencies/__init__.py b/erpnext/accounts/doctype/pegged_currencies/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/pegged_currencies/pegged_currencies.js b/erpnext/accounts/doctype/pegged_currencies/pegged_currencies.js new file mode 100644 index 00000000000..c43eb463ee8 --- /dev/null +++ b/erpnext/accounts/doctype/pegged_currencies/pegged_currencies.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Pegged Currencies", { +// refresh(frm) { + +// }, +// }); diff --git a/erpnext/accounts/doctype/pegged_currencies/pegged_currencies.json b/erpnext/accounts/doctype/pegged_currencies/pegged_currencies.json new file mode 100644 index 00000000000..e8b3bc72d8d --- /dev/null +++ b/erpnext/accounts/doctype/pegged_currencies/pegged_currencies.json @@ -0,0 +1,47 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-05-30 11:47:03.670913", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "pegged_currencies_item_section", + "pegged_currency_item" + ], + "fields": [ + { + "fieldname": "pegged_currencies_item_section", + "fieldtype": "Section Break" + }, + { + "fieldname": "pegged_currency_item", + "fieldtype": "Table", + "options": "Pegged Currency Details" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2025-06-02 11:46:31.936714", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Pegged Currencies", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/pegged_currencies/pegged_currencies.py b/erpnext/accounts/doctype/pegged_currencies/pegged_currencies.py new file mode 100644 index 00000000000..91babc17537 --- /dev/null +++ b/erpnext/accounts/doctype/pegged_currencies/pegged_currencies.py @@ -0,0 +1,22 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class PeggedCurrencies(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + from erpnext.accounts.doctype.pegged_currencies.pegged_currencies import PeggedCurrencies + + pegged_currency_item: DF.Table[PeggedCurrencies] + # end: auto-generated types + + pass diff --git a/erpnext/accounts/doctype/pegged_currencies/test_pegged_currencies.py b/erpnext/accounts/doctype/pegged_currencies/test_pegged_currencies.py new file mode 100644 index 00000000000..32bb3f34fd5 --- /dev/null +++ b/erpnext/accounts/doctype/pegged_currencies/test_pegged_currencies.py @@ -0,0 +1,29 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class UnitTestPeggedCurrencies(UnitTestCase): + """ + Unit tests for PeggedCurrencies. + Use this class for testing individual functions and methods. + """ + + pass + + +class IntegrationTestPeggedCurrencies(IntegrationTestCase): + """ + Integration tests for PeggedCurrencies. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/erpnext/accounts/doctype/pegged_currency_details/__init__.py b/erpnext/accounts/doctype/pegged_currency_details/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/pegged_currency_details/pegged_currency_details.json b/erpnext/accounts/doctype/pegged_currency_details/pegged_currency_details.json new file mode 100644 index 00000000000..0114df23853 --- /dev/null +++ b/erpnext/accounts/doctype/pegged_currency_details/pegged_currency_details.json @@ -0,0 +1,49 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-05-30 11:59:28.219277", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "source_currency", + "pegged_against", + "pegged_exchange_rate" + ], + "fields": [ + { + "fieldname": "source_currency", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Currency", + "options": "Currency" + }, + { + "fieldname": "pegged_exchange_rate", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Exchange Rate" + }, + { + "fieldname": "pegged_against", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Pegged Against", + "options": "Currency" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-06-17 14:11:16.521193", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Pegged Currency Details", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/accounts/doctype/pegged_currency_details/pegged_currency_details.py b/erpnext/accounts/doctype/pegged_currency_details/pegged_currency_details.py new file mode 100644 index 00000000000..eca2178674a --- /dev/null +++ b/erpnext/accounts/doctype/pegged_currency_details/pegged_currency_details.py @@ -0,0 +1,25 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class PeggedCurrencyDetails(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + pegged_against: DF.Link | None + pegged_exchange_rate: DF.Data | None + source_currency: DF.Link | None + # end: auto-generated types + + pass diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 01269db4ba4..a86a5dda20c 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -421,3 +421,4 @@ erpnext.patches.v14_0.update_full_name_in_contract erpnext.patches.v15_0.drop_sle_indexes execute:frappe.db.set_single_value("Accounts Settings", "confirm_before_resetting_posting_date", 1) erpnext.patches.v15_0.rename_pos_closing_entry_fields #2025-06-13 +erpnext.patches.v15_0.update_pegged_currencies diff --git a/erpnext/patches/v15_0/update_pegged_currencies.py b/erpnext/patches/v15_0/update_pegged_currencies.py new file mode 100644 index 00000000000..c74e55fd5ed --- /dev/null +++ b/erpnext/patches/v15_0/update_pegged_currencies.py @@ -0,0 +1,7 @@ +import frappe + +from erpnext.setup.install import update_pegged_currencies + + +def execute(): + update_pegged_currencies() diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index 714ef5c79cf..831bb8317e3 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -6,6 +6,7 @@ from frappe.tests import IntegrationTestCase, change_settings from frappe.utils import add_days, add_months, flt, getdate, nowdate from erpnext.controllers.accounts_controller import InvalidQtyError +from erpnext.setup.utils import get_exchange_rate EXTRA_TEST_RECORD_DEPENDENCIES = ["Product Bundle"] @@ -863,6 +864,24 @@ class TestQuotation(IntegrationTestCase): quotation.reload() self.assertEqual(quotation.status, "Ordered") + @change_settings("Accounts Settings", {"allow_pegged_currencies_exchange_rates": True}) + def test_make_quotation_qar_to_inr(self): + quotation = make_quotation( + currency="QAR", + transaction_date="2026-06-04", + ) + + cache = frappe.cache() + key = "currency_exchange_rate_{}:{}:{}".format("2026-06-04", "QAR", "INR") + value = cache.get(key) + expected_rate = flt(value) / 3.64 + + self.assertEqual( + quotation.conversion_rate, + expected_rate, + f"Expected conversion rate {expected_rate}, got {quotation.conversion_rate}", + ) + def enable_calculate_bundle_price(enable=1): selling_settings = frappe.get_doc("Selling Settings") diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index a0828856253..d4fe40c1f25 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -32,6 +32,7 @@ def after_install(): add_app_name() update_roles() make_default_operations() + update_pegged_currencies() frappe.db.commit() @@ -223,6 +224,27 @@ def create_default_role_profiles(): role_profile.insert(ignore_permissions=True) +def update_pegged_currencies(): + doc = frappe.get_doc("Pegged Currencies", "Pegged Currencies") + + existing_sources = {item.source_currency for item in doc.pegged_currency_item} + + currencies_to_add = [ + {"source_currency": "AED", "pegged_against": "USD", "pegged_exchange_rate": 3.6725}, + {"source_currency": "BHD", "pegged_against": "USD", "pegged_exchange_rate": 0.376}, + {"source_currency": "JOD", "pegged_against": "USD", "pegged_exchange_rate": 0.709}, + {"source_currency": "OMR", "pegged_against": "USD", "pegged_exchange_rate": 0.3845}, + {"source_currency": "QAR", "pegged_against": "USD", "pegged_exchange_rate": 3.64}, + {"source_currency": "SAR", "pegged_against": "USD", "pegged_exchange_rate": 3.75}, + ] + + for currency in currencies_to_add: + if currency["source_currency"] not in existing_sources: + doc.append("pegged_currency_item", currency) + + doc.save() + + DEFAULT_ROLE_PROFILES = { "Inventory": [ "Stock User", diff --git a/erpnext/setup/utils.py b/erpnext/setup/utils.py index 0d8a34806aa..814cf8cb148 100644 --- a/erpnext/setup/utils.py +++ b/erpnext/setup/utils.py @@ -9,10 +9,6 @@ from frappe.utils.nestedset import get_root_of from erpnext import get_default_company -PEGGED_CURRENCIES = { - "USD": {"AED": 3.6725}, # AED is pegged to USD at a rate of 3.6725 since 1997 -} - def before_tests(): frappe.clear_cache() @@ -47,11 +43,51 @@ def before_tests(): frappe.db.commit() -def get_pegged_rate(from_currency: str, to_currency: str, transaction_date) -> float | None: - if rate := PEGGED_CURRENCIES.get(from_currency, {}).get(to_currency): - return rate - elif rate := PEGGED_CURRENCIES.get(to_currency, {}).get(from_currency): - return 1 / rate +def get_pegged_currencies(): + pegged_currencies = frappe.get_all( + "Pegged Currency Details", + filters={"parent": "Pegged Currencies"}, + fields=["source_currency", "pegged_against", "pegged_exchange_rate"], + ) + + pegged_map = { + currency.source_currency: { + "pegged_against": currency.pegged_against, + "ratio": flt(currency.pegged_exchange_rate), + } + for currency in pegged_currencies + } + return pegged_map + + +def get_pegged_rate(pegged_map, from_currency, to_currency, transaction_date=None): + from_entry = pegged_map.get(from_currency) + to_entry = pegged_map.get(to_currency) + + if from_currency in pegged_map and to_currency in pegged_map: + # Case 1: Both are present and pegged to same bases + if from_entry["pegged_against"] == to_entry["pegged_against"]: + return (1 / from_entry["ratio"]) * to_entry["ratio"] + + # Case 2: Both are present but pegged to different bases + base_from = from_entry["pegged_against"] + base_to = to_entry["pegged_against"] + base_rate = get_exchange_rate(base_from, base_to, transaction_date) + + if not base_rate: + return None + + return (1 / from_entry["ratio"]) * base_rate * to_entry["ratio"] + + # Case 3: from_currency is pegged to to_currency + if from_entry and from_entry["pegged_against"] == to_currency: + return flt(from_entry["ratio"]) + + # Case 4: to_currency is pegged to from_currency + if to_entry and to_entry["pegged_against"] == from_currency: + return 1 / flt(to_entry["ratio"]) + + """ If only one entry exists but doesn’t match pegged currency logic, return None """ return None @@ -95,8 +131,12 @@ def get_exchange_rate(from_currency, to_currency, transaction_date=None, args=No if frappe.get_cached_value("Currency Exchange Settings", "Currency Exchange Settings", "disabled"): return 0.00 - if rate := get_pegged_rate(from_currency, to_currency, transaction_date): - return rate + pegged_currencies = {} + + if currency_settings.allow_pegged_currencies_exchange_rates: + pegged_currencies = get_pegged_currencies() + if rate := get_pegged_rate(pegged_currencies, from_currency, to_currency, transaction_date): + return rate try: cache = frappe.cache() @@ -109,8 +149,12 @@ def get_exchange_rate(from_currency, to_currency, transaction_date=None, args=No settings = frappe.get_cached_doc("Currency Exchange Settings") req_params = { "transaction_date": transaction_date, - "from_currency": from_currency if from_currency != "AED" else "USD", - "to_currency": to_currency if to_currency != "AED" else "USD", + "from_currency": from_currency + if from_currency not in pegged_currencies + else pegged_currencies[from_currency]["pegged_against"], + "to_currency": to_currency + if to_currency not in pegged_currencies + else pegged_currencies[to_currency]["pegged_against"], } params = {} for row in settings.req_params: @@ -123,12 +167,13 @@ def get_exchange_rate(from_currency, to_currency, transaction_date=None, args=No value = value[format_ces_api(str(res_key.key), req_params)] cache.setex(name=key, time=21600, value=flt(value)) - # Support AED conversion through pegged USD + # Support multiple pegged currencies value = flt(value) - if to_currency == "AED": - value *= 3.6725 - if from_currency == "AED": - value /= 3.6725 + + if currency_settings.allow_pegged_currencies_exchange_rates and to_currency in pegged_currencies: + value *= flt(pegged_currencies[to_currency]["ratio"]) + if currency_settings.allow_pegged_currencies_exchange_rates and from_currency in pegged_currencies: + value /= flt(pegged_currencies[from_currency]["ratio"]) return flt(value) except Exception: