From d387155e162ca8f3a3918f721eb1cd4a4e2fd3f8 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 9 Jul 2026 18:26:14 +0530 Subject: [PATCH 1/3] test: seed current-dated USD<->INR exchange rate in bootstrap Tests that create USD documents dated today() (e.g. Sales Order in test_advance_payment_ledger_entry, USD BOM in test_routing) rely on get_exchange_rate() finding a USD->INR Currency Exchange record. The only seeded records are dated 2016, so the lookup misses and falls back to an external API that is blocked in CI, returning 0. That surfaces as "Exchange Rate is mandatory" on Sales Order validation and a ZeroDivisionError in BOM.get_routing (hour_rate / conversion_rate). Whether it passes depends on which shard incidentally committed the 2016 records first, making it an order-dependent flake that unrelated PRs trip by shifting test distribution. Seed today()-dated USD<->INR rates once in BootStrapTestData so the lookup resolves deterministically without the external API. Rates mirror the latest Currency Exchange test_records to keep cost calculations unchanged. --- erpnext/tests/utils.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/erpnext/tests/utils.py b/erpnext/tests/utils.py index deeb8310d9c..61800eef0a0 100644 --- a/erpnext/tests/utils.py +++ b/erpnext/tests/utils.py @@ -181,6 +181,7 @@ class BootStrapTestData: self.make_location() self.make_price_list() self.make_item_price() + self.make_currency_exchange() self.make_loyalty_program() self.make_shareholder() self.make_sales_taxes_template() @@ -2533,6 +2534,38 @@ class BootStrapTestData: ] self.make_records(["item_code", "price_list", "price_list_rate"], records) + def make_currency_exchange(self): + """Seed current-dated USD<->INR rates so foreign-currency documents + transacted on ``today()`` resolve an exchange rate deterministically. + + Without this, ``get_exchange_rate`` finds no in-window Currency Exchange + record and falls back to an external API that is unreachable in CI, + returning ``0`` and breaking tests that create USD documents. The rates + mirror the latest values in the Currency Exchange ``test_records`` so + cost calculations stay unchanged regardless of which record is picked. + """ + records = [ + { + "doctype": "Currency Exchange", + "date": today(), + "from_currency": "USD", + "to_currency": "INR", + "exchange_rate": 62.9, + "for_buying": 1, + "for_selling": 1, + }, + { + "doctype": "Currency Exchange", + "date": today(), + "from_currency": "INR", + "to_currency": "USD", + "exchange_rate": 0.0167, + "for_buying": 1, + "for_selling": 1, + }, + ] + self.make_records(["from_currency", "to_currency", "date"], records) + def make_operation(self): records = [ {"doctype": "Operation", "name": "_Test Operation 1", "workstation": "_Test Workstation 1"} From e1e56b6920b6e9095e3a6c366870d305a6d440b3 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sun, 12 Jul 2026 21:12:29 +0530 Subject: [PATCH 2/3] test: adjust currency tests for deterministic seeded exchange rate Seeding a current-dated USD->INR rate makes get_exchange_rate resolve 62.9 on today() instead of hitting the live API, which exposed three tests that implicitly relied on a different/undefined current rate: - customer: dropped its own colliding current-dated seed (ignored via ignore_if_duplicate, and its cleanup deleted the shared seed) and now asserts the quotation resolves the seeded rate via get_exchange_rate. - exchange_rate_revaluation: the revalued rate (62.9) is now below the booked 80, so the revaluation is a loss (debited) rather than a gain; derive the gain/loss column from the sign instead of assuming a gain. - purchase_invoice: the receipt rate was an accidental tuple (70,) that got discarded and recomputed to the seed; set explicit rates with the receipt above the invoice so the stock exchange difference is a credit, matching the asserted column. --- .../test_exchange_rate_revaluation.py | 7 ++++++- .../purchase_invoice/test_purchase_invoice.py | 4 ++-- .../selling/doctype/customer/test_customer.py | 20 +++++-------------- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py index 5a37bccaafb..3e5b08d069d 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py @@ -348,10 +348,15 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin): je.reload() self.assertEqual(je.voucher_type, "Exchange Rate Revaluation") self.assertEqual(len(je.accounts), 3) + # A gain is credited to the gain/loss account, a loss is debited. The current + # exchange rate (from master data) may sit either side of the booked rate, so + # derive the column from the sign instead of assuming a gain. + gain_loss_debit = abs(total_gain_loss) if total_gain_loss < 0 else 0.0 + gain_loss_credit = total_gain_loss if total_gain_loss > 0 else 0.0 expected = [ (usd_account, new_balance, 0.0, 100.0, 0.0), (usd_account, 0.0, old_balance, 0.0, 100.0), - (gain_loss_account, 0.0, total_gain_loss, 0.0, total_gain_loss), + (gain_loss_account, gain_loss_debit, gain_loss_credit, gain_loss_debit, gain_loss_credit), ] actual = [] for acc in je.accounts: diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index 17afc03dde1..e60d3f4614c 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -472,7 +472,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin): pr = frappe.new_doc("Purchase Receipt") pr.currency = "USD" pr.company = "_Test Company with perpetual inventory" - pr.conversion_rate = (70,) + pr.conversion_rate = 80 pr.supplier = "_Test Supplier USD" pr.append( "items", @@ -491,7 +491,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin): # Createing purchase invoice against Purchase Receipt pi = create_purchase_invoice(pr.name) - pi.conversion_rate = 80 + pi.conversion_rate = 70 pi.credit_to = "_Test Payable USD - TCP1" pi.insert() pi.submit() diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index a1b15a1e867..c1315fe518b 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -17,6 +17,7 @@ from erpnext.selling.doctype.customer.mapper import ( make_quotation, parse_full_name, ) +from erpnext.setup.utils import get_exchange_rate from erpnext.tests.utils import ERPNextTestSuite @@ -29,20 +30,9 @@ class TestCustomer(ERPNextTestSuite): frappe.defaults.set_user_default("company", company) self.addCleanup(frappe.defaults.clear_user_default, "company") - # Seed a deterministic rate so the test does not depend on the live exchange-rate API. - rate = 83.0 - exchange = frappe.get_doc( - { - "doctype": "Currency Exchange", - "date": nowdate(), - "from_currency": foreign_currency, - "to_currency": company_currency, - "exchange_rate": rate, - "for_selling": 1, - "for_buying": 1, - } - ).insert(ignore_if_duplicate=True) - self.addCleanup(frappe.delete_doc, "Currency Exchange", exchange.name, force=1) + # Master data seeds a current-dated exchange rate, so make_quotation should + # resolve that rate instead of falling back to the default conversion rate of 1.0. + expected_rate = get_exchange_rate(foreign_currency, company_currency, nowdate()) customer = frappe.get_doc( { @@ -59,7 +49,7 @@ class TestCustomer(ERPNextTestSuite): self.assertEqual(quotation.currency, foreign_currency) self.assertNotEqual(flt(quotation.conversion_rate), 1.0) self.assertNotEqual(flt(quotation.conversion_rate), 0.0) - self.assertEqual(flt(quotation.conversion_rate), rate) + self.assertEqual(flt(quotation.conversion_rate), flt(expected_rate)) def test_get_customer_name_dedupes_with_numeric_suffix(self): # When a customer name already exists, get_customer_name appends "- ". The From 5133ba47b7f7d7b05691252b7df1caf2f877c085 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Tue, 14 Jul 2026 18:02:59 +0530 Subject: [PATCH 3/3] fix: make currency exchange truly idempotent against any pre-existing state Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- erpnext/tests/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/tests/utils.py b/erpnext/tests/utils.py index 61800eef0a0..aebb7a22650 100644 --- a/erpnext/tests/utils.py +++ b/erpnext/tests/utils.py @@ -2564,7 +2564,7 @@ class BootStrapTestData: "for_selling": 1, }, ] - self.make_records(["from_currency", "to_currency", "date"], records) + self.make_records(["from_currency", "to_currency", "date", "for_buying", "for_selling"], records) def make_operation(self): records = [