diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 292f722aff0..0ca9309fa71 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -45,6 +45,7 @@ "role_to_override_stop_action", "currency_exchange_section", "allow_stale", + "allow_pegged_currencies_exchange_rates", "column_break_yuug", "stale_days", "section_break_jpd0", @@ -592,6 +593,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" } ], "icon": "icon-cog", @@ -599,7 +607,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-05-05 12:29:38.302027", + "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 8dd73491072..2a3cada2398 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/bank_transaction/auto_match_party.py b/erpnext/accounts/doctype/bank_transaction/auto_match_party.py index 66aab9d62dd..63c40a0c621 100644 --- a/erpnext/accounts/doctype/bank_transaction/auto_match_party.py +++ b/erpnext/accounts/doctype/bank_transaction/auto_match_party.py @@ -1,6 +1,7 @@ import frappe from frappe.utils import flt from rapidfuzz import fuzz, process +from rapidfuzz.utils import default_process class AutoMatchParty: @@ -132,6 +133,7 @@ class AutoMatchbyPartyNameDescription: query=self.get(field), choices={row.get("name"): row.get("party_name") for row in names}, scorer=fuzz.token_set_ratio, + processor=default_process, ) party_name, skip = self.process_fuzzy_result(result) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index f0566f44368..9d70b85f5c7 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -7,10 +7,10 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "naming_series", "budget_against", "company", "cost_center", - "naming_series", "project", "fiscal_year", "column_break_3", @@ -195,19 +195,19 @@ }, { "fieldname": "naming_series", - "fieldtype": "Data", - "hidden": 1, + "fieldtype": "Select", "label": "Series", "no_copy": 1, + "options": "BUDGET-.YYYY.-", "print_hide": 1, - "read_only": 1, + "reqd": 1, "set_only_once": 1 } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-10-10 22:14:36.361509", + "modified": "2025-06-16 15:57:13.114981", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", @@ -235,4 +235,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 2f2b549cf46..d31f72f062e 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -48,7 +48,7 @@ class Budget(Document): cost_center: DF.Link | None fiscal_year: DF.Link monthly_distribution: DF.Link | None - naming_series: DF.Data | None + naming_series: DF.Literal["BUDGET-.YYYY.-"] project: DF.Link | None # end: auto-generated types @@ -136,9 +136,6 @@ class Budget(Document): ): self.applicable_on_booking_actual_expenses = 1 - def before_naming(self): - self.naming_series = f"{{{frappe.scrub(self.budget_against)}}}./.{self.fiscal_year}/.###" - def validate_expense_against_budget(args, expense_amount=0): args = frappe._dict(args) 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..9171685a805 --- /dev/null +++ b/erpnext/accounts/doctype/pegged_currencies/test_pegged_currencies.py @@ -0,0 +1,9 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestPeggedCurrencies(FrappeTestCase): + 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/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py index 8216a9e7259..3d6d03f7380 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py @@ -258,6 +258,7 @@ class POSInvoiceMergeLog(Document): if not found: tax.charge_type = "Actual" tax.idx = idx + tax.row_id = None idx += 1 tax.included_in_print_rate = 0 tax.tax_amount = tax.tax_amount_after_discount_amount diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 52550f148b7..38dc1e7502d 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -424,6 +424,8 @@ def get_party_account(party_type, party=None, company=None, include_advance=Fals Will first search in party (Customer / Supplier) record, if not found, will search in group (Customer Group / Supplier Group), finally will return default.""" + if not party_type: + frappe.throw(_("Party Type is mandatory")) if not company: frappe.throw(_("Please select a Company")) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 8c0a2b708af..e87fa699908 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -49,7 +49,8 @@ class ReceivablePayableReport: self.filters.report_date = getdate(self.filters.report_date or nowdate()) self.age_as_on = ( getdate(nowdate()) - if self.filters.calculate_ageing_with == "Today Date" + if "calculate_ageing_with" not in self.filters + or self.filters.calculate_ageing_with == "Today Date" else self.filters.report_date ) diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index 0bb14604991..7757bb37f6b 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -194,7 +194,8 @@ def get_gl_entries(filters, accounting_dimensions): voucher_type, voucher_subtype, voucher_no, {dimension_fields} cost_center, project, {transaction_currency_fields} against_voucher_type, against_voucher, account_currency, - against, is_opening, creation {select_fields} + against, is_opening, creation {select_fields}, + transaction_currency from `tabGL Entry` where company=%(company)s {get_conditions(filters)} {order_by_statement} diff --git a/erpnext/accounts/report/general_ledger/test_general_ledger.py b/erpnext/accounts/report/general_ledger/test_general_ledger.py index 2684aa326e6..e1ce1bd35a2 100644 --- a/erpnext/accounts/report/general_ledger/test_general_ledger.py +++ b/erpnext/accounts/report/general_ledger/test_general_ledger.py @@ -3,12 +3,15 @@ import frappe from frappe import qb -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import flt, today +from erpnext.accounts.doctype.account.test_account import create_account +from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.report.general_ledger.general_ledger import execute from erpnext.controllers.sales_and_purchase_return import make_return_doc +from erpnext.selling.doctype.customer.test_customer import create_internal_customer class TestGeneralLedger(FrappeTestCase): @@ -168,6 +171,90 @@ class TestGeneralLedger(FrappeTestCase): self.assertEqual(data[3]["debit"], 100) self.assertEqual(data[3]["credit"], 100) + @change_settings("Accounts Settings", {"delete_linked_ledger_entries": True}) + def test_debit_in_exchange_gain_loss_account(self): + company = "_Test Company" + + exchange_gain_loss_account = frappe.db.get_value("Company", "exchange_gain_loss_account") + if not exchange_gain_loss_account: + frappe.db.set_value( + "Company", company, "exchange_gain_loss_account", "_Test Exchange Gain/Loss - _TC" + ) + + account_name = "_Test Receivable USD - _TC" + customer_name = "_Test Customer USD" + + sales_invoice = create_sales_invoice( + company=company, + customer=customer_name, + currency="USD", + debit_to=account_name, + conversion_rate=85, + posting_date=today(), + ) + + payment_entry = create_payment_entry( + company=company, + party_type="Customer", + party=customer_name, + payment_type="Receive", + paid_from=account_name, + paid_from_account_currency="USD", + paid_to="Cash - _TC", + paid_to_account_currency="INR", + paid_amount=10, + do_not_submit=True, + ) + payment_entry.base_paid_amount = 800 + payment_entry.received_amount = 800 + payment_entry.currency = "USD" + payment_entry.source_exchange_rate = 80 + payment_entry.append( + "references", + frappe._dict( + { + "reference_doctype": "Sales Invoice", + "reference_name": sales_invoice.name, + "total_amount": 10, + "outstanding_amount": 10, + "exchange_rate": 85, + "allocated_amount": 10, + "exchange_gain_loss": -50, + } + ), + ) + payment_entry.save() + payment_entry.submit() + + journal_entry = frappe.get_all( + "Journal Entry Account", filters={"reference_name": sales_invoice.name}, fields=["parent"] + ) + + columns, data = execute( + frappe._dict( + { + "company": company, + "from_date": today(), + "to_date": today(), + "include_dimensions": 1, + "include_default_book_entries": 1, + "account": ["_Test Exchange Gain/Loss - _TC"], + "categorize_by": "Categorize by Voucher (Consolidated)", + } + ) + ) + + entry = data[1] + self.assertEqual(entry["debit"], 50) + self.assertEqual(entry["voucher_type"], "Journal Entry") + self.assertEqual(entry["voucher_no"], journal_entry[0]["parent"]) + + payment_entry.cancel() + payment_entry.delete() + sales_invoice.reload() + sales_invoice.cancel() + sales_invoice.delete() + def test_ignore_exchange_rate_journals_filter(self): # create a new account with USD currency account_name = "Test Debtors USD" diff --git a/erpnext/accounts/report/utils.py b/erpnext/accounts/report/utils.py index 2a72b10e4eb..5056b986187 100644 --- a/erpnext/accounts/report/utils.py +++ b/erpnext/accounts/report/utils.py @@ -101,13 +101,18 @@ def convert_to_presentation_currency(gl_entries, currency_info): account_currencies = list(set(entry["account_currency"] for entry in gl_entries)) for entry in gl_entries: + transaction_currency = entry.get("transaction_currency") debit = flt(entry["debit"]) credit = flt(entry["credit"]) debit_in_account_currency = flt(entry["debit_in_account_currency"]) credit_in_account_currency = flt(entry["credit_in_account_currency"]) account_currency = entry["account_currency"] - if len(account_currencies) == 1 and account_currency == presentation_currency: + if ( + len(account_currencies) == 1 + and account_currency == presentation_currency + and (transaction_currency is None or account_currency == transaction_currency) + ): entry["debit"] = debit_in_account_currency entry["credit"] = credit_in_account_currency else: diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index 3ae91f0593b..9fecd26a68a 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -72,6 +72,12 @@ frappe.ui.form.on("Asset", { filters: { item_code: doc.item_code }, }; }); + + if (frm.doc.docstatus == 1) { + frm.custom_make_buttons = { + "Asset Capitalization": "Asset Capitalization", + }; + } }, refresh: function (frm) { diff --git a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py index 0f4d8a9ae95..7f5d480538b 100644 --- a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py +++ b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py @@ -206,6 +206,7 @@ class AssetValueAdjustment(Document): ) asset.flags.ignore_validate_update_after_submit = True asset.save() + asset.set_status() @frappe.whitelist() diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index f3e66cba663..8390ecada10 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -394,7 +394,7 @@ def get_batch_no(doctype, txt, searchfield, start, page_len, filters): doctype = "Batch" meta = frappe.get_meta(doctype, cached=True) searchfields = meta.get_search_fields() - page_len = 30 + page_len = 300 batches = get_batches_from_stock_ledger_entries(searchfields, txt, filters, start, page_len) batches.extend(get_batches_from_serial_and_batch_bundle(searchfields, txt, filters, start, page_len)) diff --git a/erpnext/manufacturing/doctype/bom/bom.json b/erpnext/manufacturing/doctype/bom/bom.json index 58ce7c50583..5be71cdf9d2 100644 --- a/erpnext/manufacturing/doctype/bom/bom.json +++ b/erpnext/manufacturing/doctype/bom/bom.json @@ -131,6 +131,7 @@ "fieldname": "quantity", "fieldtype": "Float", "label": "Quantity", + "non_negative": 1, "oldfieldname": "quantity", "oldfieldtype": "Currency", "reqd": 1 @@ -637,7 +638,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2024-06-03 16:24:47.518411", + "modified": "2025-06-16 16:13:22.497695", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM", @@ -670,6 +671,7 @@ "write": 1 } ], + "row_format": "Dynamic", "search_fields": "item, item_name", "show_name_in_global_search": 1, "sort_field": "modified", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index e4ed92e7d3d..68360b696c0 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -1184,6 +1184,7 @@ def get_exploded_items(item_details, company, bom_no, include_non_stock_items, p item.purchase_uom, item_uom.conversion_factor, item.safety_stock, + bom.item.as_("main_bom_item"), ) .where( (bei.docstatus < 2) @@ -1927,6 +1928,7 @@ def get_raw_materials_of_sub_assembly_items( item.purchase_uom, item_uom.conversion_factor, item.safety_stock, + bom.item.as_("main_bom_item"), ) .where( (bei.docstatus == 1) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index ca3e61f5d26..13be80338d8 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -409,3 +409,4 @@ erpnext.patches.v15_0.set_cancelled_status_to_cancelled_pos_invoice erpnext.patches.v15_0.rename_group_by_to_categorize_by_in_custom_reports erpnext.patches.v14_0.update_full_name_in_contract erpnext.patches.v15_0.drop_sle_indexes +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/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 74a814b20f0..b6f008f9664 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -987,7 +987,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } var party = me.frm.doc[frappe.model.scrub(party_type)]; - if(party && me.frm.doc.company) { + if(party && me.frm.doc.company && (!me.frm.doc.__onload?.load_after_mapping || !me.frm.doc.get(party_account_field))) { return frappe.call({ method: "erpnext.accounts.party.get_party_account", args: { diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index 2dd08b91b78..0e58b950e99 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -6,6 +6,7 @@ from frappe.tests.utils import FrappeTestCase, 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 test_dependencies = ["Product Bundle"] @@ -861,6 +862,24 @@ class TestQuotation(FrappeTestCase): 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}", + ) + test_records = frappe.get_test_records("Quotation") diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index 81e1ac6a458..b15925ea232 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -35,6 +35,7 @@ def after_install(): add_app_name() hide_workspaces() update_roles() + update_pegged_currencies() frappe.db.commit() @@ -242,6 +243,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 e41f27b4d8c..9fb036a48d9 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() @@ -49,11 +45,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 @@ -97,8 +133,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() @@ -111,8 +151,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: @@ -125,12 +169,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: diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index c8d2238f490..2485f704b76 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -883,7 +883,10 @@ class update_entries_after: self.wh_data.valuation_rate ) - if sle.actual_qty < 0 and self.wh_data.qty_after_transaction != 0: + if ( + sle.actual_qty < 0 + and flt(self.wh_data.qty_after_transaction, self.flt_precision) != 0 + ): self.wh_data.valuation_rate = flt( self.wh_data.stock_value, self.currency_precision ) / flt(self.wh_data.qty_after_transaction, self.flt_precision) diff --git a/pyproject.toml b/pyproject.toml index e122b2d176c..fc872af4d61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "pycountry~=22.3.5", "Unidecode~=1.3.6", "barcodenumber~=0.5.0", - "rapidfuzz~=2.15.0", + "rapidfuzz~=3.12.2", "holidays~=0.28", # integration dependencies