diff --git a/currency_rate_update_BI/README.rst b/currency_rate_update_BI/README.rst new file mode 100644 index 00000000..71a22ffd --- /dev/null +++ b/currency_rate_update_BI/README.rst @@ -0,0 +1,175 @@ +==================================== +Currency Rate Update: Bank Indonesia +==================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:manual + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fcurrency-lightgray.png?logo=github + :target: https://github.com/OCA/currency/tree/16.0/currency_rate_update_BI + :alt: OCA/currency +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/currency-16-0/currency-16-0-currency_rate_update_BI + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/currency&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Adds **Bank Indonesia (BI)** as a currency rates provider for the +``currency_rate_update`` module. Exchange rates are fetched from +Bank Indonesia's official SOAP webservice (wsKursBI) using the +``getSubKursLokal3`` method. + +**Data Source:** Bank Indonesia — https://www.bi.go.id + +**Rate Type:** Kurs Transaksi BI (Transaction Rates — middle rate / kurs tengah) + +**Update Frequency:** Daily on business days (Mon–Fri, excluding Indonesian national holidays) + +**Supported Currencies (vs IDR):** + ++-----+----------------------------+ +| USD | United States Dollar | ++-----+----------------------------+ +| EUR | Euro | ++-----+----------------------------+ +| GBP | British Pound | ++-----+----------------------------+ +| JPY | Japanese Yen | ++-----+----------------------------+ +| SGD | Singapore Dollar | ++-----+----------------------------+ +| AUD | Australian Dollar | ++-----+----------------------------+ +| BND | Brunei Dollar | ++-----+----------------------------+ +| CAD | Canadian Dollar | ++-----+----------------------------+ +| CHF | Swiss Franc | ++-----+----------------------------+ +| CNY | Chinese Yuan | ++-----+----------------------------+ +| DKK | Danish Krone | ++-----+----------------------------+ +| HKD | Hong Kong Dollar | ++-----+----------------------------+ +| KRW | South Korean Won | ++-----+----------------------------+ +| MYR | Malaysian Ringgit | ++-----+----------------------------+ +| NOK | Norwegian Krone | ++-----+----------------------------+ +| NZD | New Zealand Dollar | ++-----+----------------------------+ +| SAR | Saudi Riyal | ++-----+----------------------------+ +| SEK | Swedish Krona | ++-----+----------------------------+ +| THB | Thai Baht | ++-----+----------------------------+ + +**Note:** This module is designed primarily for Indonesian companies +using **IDR (Indonesian Rupiah)** as their base currency. Cross-rate +calculations (e.g. USD base company wanting EUR rates) are supported +as long as the base currency is in the list above. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To configure Bank Indonesia currency rates provider: + +#. Go to *Invoicing > Configuration > Settings* +#. Ensure *Automatic Currency Rates (OCA)* is checked +#. Go to *Invoicing > Configuration > Currency Rates Providers* +#. Create a new provider with *Provider: Bank Indonesia (Kurs Transaksi)* +#. Select the currencies you want to update (USD, EUR, SGD, etc.) +#. Configure the update schedule (e.g., daily, weekly) +#. Save the provider + +.. note:: + Your company's base currency should be **IDR (Indonesian Rupiah)** + or one of the supported foreign currencies if you need cross-rate + calculations. + +Usage +===== + +After configuration, currency rates are automatically updated based on +your schedule. To manually update rates: + +#. Go to *Invoicing > Configuration > Currency Rates Providers* +#. Select the Bank Indonesia provider +#. Click *Action > Update Rates Wizard* +#. Set the date range (From/To dates) +#. Click *Update* to fetch rates from Bank Indonesia + +To view the fetched rates: + +#. Go to *Invoicing > Configuration > Currencies* +#. Select a currency (e.g., USD) +#. Open the *Rates* tab to see historical exchange rates + +The rates are fetched from Bank Indonesia's official webservice at +https://www.bi.go.id/biwebservice/wskursbi.asmx and represent the +middle rate (average of buy/sell rates) for each currency pair. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* PT Solusi Aglis Indonesia + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-hitrosol| image:: https://github.com/hitrosol.png?size=40px + :target: https://github.com/hitrosol + :alt: hitrosol + +Current `maintainer `__: + +|maintainer-hitrosol| + +This module is part of the `OCA/currency `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/currency_rate_update_BI/__init__.py b/currency_rate_update_BI/__init__.py new file mode 100644 index 00000000..4f71d542 --- /dev/null +++ b/currency_rate_update_BI/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from .hooks import post_init_hook +from . import models diff --git a/currency_rate_update_BI/__manifest__.py b/currency_rate_update_BI/__manifest__.py new file mode 100644 index 00000000..a290493f --- /dev/null +++ b/currency_rate_update_BI/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2026 PT Solusi Aglis Indonesia (http://solusiaglis.co.id) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + "name": "Currency Rate Update: Bank Indonesia", + "version": "16.0.1.0.0", + "category": "Financial Management/Configuration", + "summary": "Update exchange rates using Bank Indonesia (BI) official rates", + "author": "PT Solusi Aglis Indonesia, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/currency", + "license": "AGPL-3", + "installable": True, + "application": False, + "depends": ["currency_rate_update"], + "post_init_hook": "post_init_hook", + "maintainers": ["hitrosol"], +} diff --git a/currency_rate_update_BI/hooks.py b/currency_rate_update_BI/hooks.py new file mode 100644 index 00000000..6861f39a --- /dev/null +++ b/currency_rate_update_BI/hooks.py @@ -0,0 +1,57 @@ +# Copyright 2026 PT Solusi Aglis Indonesia (http://solusiaglis.co.id) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging + +_logger = logging.getLogger(__name__) + + +def post_init_hook(cr, registry): + """Remove stale rate records for all companies' base currencies. + + When a company switches its base currency (e.g. from USD to IDR), + historical rate records for the old foreign currency remain in + res_currency_rate. Odoo's company_rate formula divides the stored + rate by the *last* rate found for the company's current base currency: + + company_rate = rate / last_base_currency_rate + + If last_base_currency_rate != 1.0 the displayed "Unit per IDR" / + "IDR per Unit" columns become wildly incorrect, even though the raw + Technical Rate is correct. + + This hook detects and removes any such stale records so the module + works correctly on both fresh and migrated databases. + """ + cr.execute( + """ + SELECT rc.id, rc.name AS currency_name, COUNT(rcr.id) AS rate_count + FROM res_company rco + JOIN res_currency rc ON rc.id = rco.currency_id + LEFT JOIN res_currency_rate rcr ON rcr.currency_id = rc.id + GROUP BY rc.id, rc.name + HAVING COUNT(rcr.id) > 0 + """ + ) + rows = cr.fetchall() + if not rows: + return + + for currency_id, currency_name, rate_count in rows: + _logger.warning( + "currency_rate_update_BI: Found %d stale rate record(s) for base " + "currency '%s'. These cause incorrect company_rate display. " + "Removing them now.", + rate_count, + currency_name, + ) + cr.execute( + "DELETE FROM res_currency_rate WHERE currency_id = %s", + (currency_id,), + ) + _logger.info( + "currency_rate_update_BI: Removed %d rate record(s) for base " + "currency '%s'.", + rate_count, + currency_name, + ) diff --git a/currency_rate_update_BI/i18n/currency_rate_update_BI.pot b/currency_rate_update_BI/i18n/currency_rate_update_BI.pot new file mode 100644 index 00000000..0bb1df01 --- /dev/null +++ b/currency_rate_update_BI/i18n/currency_rate_update_BI.pot @@ -0,0 +1,29 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * currency_rate_update_BI +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2026-01-01 00:00+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#. module: currency_rate_update_BI +#: model_terms:res.currency.rate.provider,service:currency_rate_update_BI.service_bi +msgid "Bank Indonesia (Kurs Transaksi)" +msgstr "" + +#. module: currency_rate_update_BI +#: code:addons/currency_rate_update_BI/models/res_currency_rate_provider_BI.py:0 +msgid "Cannot connect to Bank Indonesia webservice.\nPlease check your internet connection.\nURL: %(url)s\nError: %(error)s" +msgstr "" + +#. module: currency_rate_update_BI +#: code:addons/currency_rate_update_BI/models/res_currency_rate_provider_BI.py:0 +msgid "Bank Indonesia provider: Company base currency %(currency)s is not supported. BI supports IDR as base or any of: %(list)s" +msgstr "" diff --git a/currency_rate_update_BI/i18n/id.po b/currency_rate_update_BI/i18n/id.po new file mode 100644 index 00000000..a090bde6 --- /dev/null +++ b/currency_rate_update_BI/i18n/id.po @@ -0,0 +1,31 @@ +# Indonesian translation of currency_rate_update_BI +# Copyright (C) 2026 PT Solusi Aglis Indonesia +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2026-01-01 00:00+0700\n" +"Last-Translator: PT Solusi Aglis Indonesia \n" +"Language-Team: Indonesian\n" +"Language: id\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#. module: currency_rate_update_BI +#: model_terms:res.currency.rate.provider,service:currency_rate_update_BI.service_bi +msgid "Bank Indonesia (Kurs Transaksi)" +msgstr "Bank Indonesia (Kurs Transaksi)" + +#. module: currency_rate_update_BI +#: code:addons/currency_rate_update_BI/models/res_currency_rate_provider_BI.py:0 +msgid "Cannot connect to Bank Indonesia webservice.\nPlease check your internet connection.\nURL: %(url)s\nError: %(error)s" +msgstr "Tidak dapat terhubung ke webservice Bank Indonesia.\nMohon periksa koneksi internet Anda.\nURL: %(url)s\nError: %(error)s" + +#. module: currency_rate_update_BI +#: code:addons/currency_rate_update_BI/models/res_currency_rate_provider_BI.py:0 +msgid "Bank Indonesia provider: Company base currency %(currency)s is not supported. BI supports IDR as base or any of: %(list)s" +msgstr "Provider Bank Indonesia: Mata uang dasar perusahaan %(currency)s tidak didukung. BI mendukung IDR sebagai dasar atau salah satu dari: %(list)s" diff --git a/currency_rate_update_BI/models/__init__.py b/currency_rate_update_BI/models/__init__.py new file mode 100644 index 00000000..df585f56 --- /dev/null +++ b/currency_rate_update_BI/models/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import res_currency_rate_provider_BI diff --git a/currency_rate_update_BI/models/res_currency_rate_provider_BI.py b/currency_rate_update_BI/models/res_currency_rate_provider_BI.py new file mode 100644 index 00000000..0fc197ac --- /dev/null +++ b/currency_rate_update_BI/models/res_currency_rate_provider_BI.py @@ -0,0 +1,322 @@ +# Copyright 2026 PT Solusi Aglis Indonesia (http://solusiaglis.co.id) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging +from urllib.error import URLError +from urllib.request import Request, urlopen +from xml.etree import ElementTree + +from odoo import _, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +# Bank Indonesia SOAP Webservice (HTTP GET style) +# Source: https://www.bi.go.id/biwebservice/wskursbi.asmx +BI_WEBSERVICE_URL = ( + "https://www.bi.go.id/biwebservice/wskursbi.asmx/getSubKursLokal3" + "?mts={currency}&startdate={date_from}&enddate={date_to}" +) + +# BI server requires browser-like headers, otherwise returns empty response +BI_REQUEST_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "id-ID,id;q=0.9,en;q=0.8", +} + +# Currencies published by Bank Indonesia in Kurs Transaksi BI +# Source: https://www.bi.go.id/en/statistik/informasi-kurs/transaksi-bi/ +BI_SUPPORTED_CURRENCIES = [ + "USD", # US Dollar + "EUR", # Euro + "GBP", # British Pound + "JPY", # Japanese Yen + "SGD", # Singapore Dollar + "AUD", # Australian Dollar + "BND", # Brunei Dollar + "CAD", # Canadian Dollar + "CHF", # Swiss Franc + "CNY", # Chinese Yuan + "DKK", # Danish Krone + "HKD", # Hong Kong Dollar + "KRW", # South Korean Won + "MYR", # Malaysian Ringgit + "NOK", # Norwegian Krone + "NZD", # New Zealand Dollar + "SAR", # Saudi Riyal + "SEK", # Swedish Krona + "THB", # Thai Baht +] + + +class ResCurrencyRateProviderBI(models.Model): + _inherit = "res.currency.rate.provider" + + service = fields.Selection( + selection_add=[("BI", "Bank Indonesia (Kurs Transaksi)")], + ondelete={"BI": "set default"}, + ) + + def _get_supported_currencies(self): + self.ensure_one() + if self.service != "BI": + return super()._get_supported_currencies() + + # Start with currencies that BI publishes rates for + supported = BI_SUPPORTED_CURRENCIES.copy() + + # If company base is not IDR, add IDR to supported list + # This allows selecting IDR to get its rate when base is foreign (e.g., USD) + if self.company_id.currency_id.name != "IDR": + supported.append("IDR") + + return supported + + def _obtain_rates(self, base_currency, currencies, date_from, date_to): + self.ensure_one() + if self.service != "BI": + return super()._obtain_rates(base_currency, currencies, date_from, date_to) + + # Determine which currencies to fetch from BI + fetch_currencies = self._get_currencies_to_fetch(base_currency, currencies) + if not fetch_currencies: + return {} + + # Fetch rates from BI webservice + rates_by_date = self._fetch_bi_rates(fetch_currencies, date_from, date_to) + if not rates_by_date: + return {} + + # Convert to Odoo format based on base currency + return self._convert_bi_rates_to_odoo(rates_by_date, base_currency) + + def _get_currencies_to_fetch(self, base_currency, currencies): + """Determine which currencies need to be fetched from BI.""" + fetch_currencies = [c for c in currencies if c in BI_SUPPORTED_CURRENCIES] + # If company base is not IDR but is in BI's list, we need it for cross-rate calc + if base_currency in BI_SUPPORTED_CURRENCIES and base_currency != "IDR": + if base_currency not in fetch_currencies: + fetch_currencies.append(base_currency) + return fetch_currencies + + def _fetch_bi_rates(self, fetch_currencies, date_from, date_to): + """Fetch exchange rates from BI webservice for given currencies.""" + rates_by_date = {} + for currency in fetch_currencies: + url = BI_WEBSERVICE_URL.format( + currency=currency, + date_from=date_from.strftime("%Y-%m-%d"), + date_to=date_to.strftime("%Y-%m-%d"), + ) + try: + req = Request(url, headers=BI_REQUEST_HEADERS) + with urlopen(req, timeout=30) as response: + xml_data = response.read() + currency_rates = self._parse_bi_response(xml_data, currency) + for date_str, rate_value in currency_rates.items(): + if date_str not in rates_by_date: + rates_by_date[date_str] = {} + rates_by_date[date_str][currency] = rate_value + except URLError as e: + _logger.warning( + "Currency Rate Provider BI: Failed to fetch %s rates from BI: %s", + currency, + str(e), + ) + raise UserError( + _( + "Cannot connect to Bank Indonesia webservice.\n" + "Please check your internet connection.\n" + "URL: %(url)s\nError: %(error)s" + ) + % {"url": url, "error": str(e)} + ) from e + return rates_by_date + + def _convert_bi_rates_to_odoo(self, rates_by_date, base_currency): + """Convert BI rates to Odoo format based on base currency. + + BI publishes: X IDR per 1 foreign currency (e.g. 16826 IDR/USD) + Odoo _process_rate expects: {"inverted": X} where X = IDR per 1 foreign + _process_rate will compute: direct = 1/X (foreign per 1 IDR, used as rate) + """ + if base_currency == "IDR": + return self._convert_idr_base_rates(rates_by_date) + elif base_currency in BI_SUPPORTED_CURRENCIES: + return self._convert_foreign_base_rates(rates_by_date, base_currency) + else: + raise UserError( + _( + "Bank Indonesia provider: Company base currency %(currency)s " + "is not supported. BI supports IDR as base or any of: %(list)s" + ) + % { + "currency": base_currency, + "list": ", ".join(BI_SUPPORTED_CURRENCIES), + } + ) + + def _convert_idr_base_rates(self, rates_by_date): + """Convert rates for IDR base currency.""" + content = {} + for date_str, rates in rates_by_date.items(): + content[date_str] = {} + for curr, rate_idr in rates.items(): + # rate_idr = IDR per 1 unit of `curr` (e.g. 16826 for USD) + # Odoo stores "foreign per 1 base" => rate_usd = 1/16826 + # We pass {"inverted": 16826} so _process_rate computes 1/16826 + content[date_str][curr] = {"inverted": rate_idr} + return content + + def _convert_foreign_base_rates(self, rates_by_date, base_currency): + """Convert rates for foreign base currency using cross-rates.""" + content = {} + for date_str, rates in rates_by_date.items(): + if base_currency not in rates: + # Skip dates where base currency rate is missing + continue + base_idr = rates[base_currency] # IDR per 1 base currency + content[date_str] = {} + for curr, rate_idr in rates.items(): + if curr == base_currency: + continue + # Cross rate: foreign per 1 base = (IDR/base) / (IDR/foreign) + # = rate_idr_base / rate_idr_foreign + # e.g. EUR/USD = (IDR/USD) / (IDR/EUR) = 16826 / 18500 ≈ 0.9095 + cross_rate = base_idr / rate_idr + content[date_str][curr] = cross_rate + + # Add IDR rate if needed + # When base is USD and BI provides 16826 IDR/USD, + # the rate is simply 16826 (direct rate: IDR per 1 USD) + content[date_str]["IDR"] = base_idr + return content + + def _parse_bi_response(self, xml_data, expected_currency): + """Parse BI SOAP DataSet XML response (diffgram format). + + BI's wsKursBI (getSubKursLokal3) returns a Microsoft ADO.NET DataSet + XML in diffgram format. Actual structure: + + + ... + + + + 962545 + 1.00 + 16741.87 + 16910.13 + 2026-02-06T00:00:00+07:00 + USD +
+ ... +
+
+
+ + Notes: + - No kurs_tengah; rate = (beli + jual) / 2 + - nil_subkurslokal is the nominal unit (1 for USD, may be 100 for JPY) + - mts_subkurslokal has trailing spaces + - Rate stored = (beli + jual) / 2 / nil_subkurslokal + + Returns dict: {date_str: idr_per_1_foreign_unit} + """ + result = {} + try: + root = ElementTree.fromstring(xml_data) + except ElementTree.ParseError as e: + _logger.error("BI: Failed to parse XML response: %s", str(e)) + return result + + # Strip all XML namespaces for simpler XPath queries + for elem in root.iter(): + if "}" in elem.tag: + elem.tag = elem.tag.split("}", 1)[1] + # Also strip namespaced attributes (e.g. diffgr:id) + elem.attrib = { + k.split("}", 1)[1] if "}" in k else k: v for k, v in elem.attrib.items() + } + + # Data lives inside diffgram > NewDataSet > Table + for table in root.findall(".//NewDataSet/Table"): + date_str = self._get_bi_date(table) + if not date_str: + continue + + rate_value = self._get_bi_rate(table) + if rate_value is None: + continue + + result[date_str] = rate_value + + if not result: + _logger.warning( + "BI: No exchange rate data found for %s in XML response. " + "The webservice may not have data for the requested date range, " + "or the XML structure may have changed.", + expected_currency, + ) + return result + + def _get_bi_date(self, table_element): + """Extract and normalize date from a BI Table element. + + BI returns dates as ISO 8601 with timezone: + e.g. "2026-02-06T00:00:00+07:00" + + Returns date string "YYYY-MM-DD" or None if not found. + """ + el = table_element.find("tgl_subkurslokal") + if el is not None and el.text: + return el.text.strip().split("T")[0] + return None + + def _get_bi_rate(self, table_element): + """Extract exchange rate (IDR per 1 foreign currency unit) from Table. + + BI Kurs Transaksi publishes beli (buy) and jual (sell) rates only. + Middle rate = (beli + jual) / 2. + + nil_subkurslokal is the nominal unit. For most currencies it is 1, + but some currencies (e.g. JPY, KRW) may use 100 as the unit, meaning + the listed rate is per 100 units. We divide by nil to normalize to + rate per 1 unit. + + Returns float IDR-per-1-foreign-unit, or None on error. + """ + beli_el = table_element.find("beli_subkurslokal") + jual_el = table_element.find("jual_subkurslokal") + nil_el = table_element.find("nil_subkurslokal") + + if not ( + beli_el is not None + and beli_el.text + and jual_el is not None + and jual_el.text + ): + _logger.warning( + "BI: Could not extract rate from Table element. " "Available tags: %s", + [child.tag for child in table_element], + ) + return None + + try: + beli = float(beli_el.text.strip().replace(",", "")) + jual = float(jual_el.text.strip().replace(",", "")) + nil = ( + float(nil_el.text.strip()) + if nil_el is not None and nil_el.text + else 1.0 + ) + if nil <= 0: + nil = 1.0 + return (beli + jual) / 2 / nil + except (ValueError, ZeroDivisionError): + return None diff --git a/currency_rate_update_BI/readme/CONFIGURE.rst b/currency_rate_update_BI/readme/CONFIGURE.rst new file mode 100644 index 00000000..5472f861 --- /dev/null +++ b/currency_rate_update_BI/readme/CONFIGURE.rst @@ -0,0 +1,14 @@ +To configure Bank Indonesia currency rates provider: + +#. Go to *Invoicing > Configuration > Settings* +#. Ensure *Automatic Currency Rates (OCA)* is checked +#. Go to *Invoicing > Configuration > Currency Rates Providers* +#. Create a new provider with *Provider: Bank Indonesia (Kurs Transaksi)* +#. Select the currencies you want to update (USD, EUR, SGD, etc.) +#. Configure the update schedule (e.g., daily, weekly) +#. Save the provider + +.. note:: + Your company's base currency should be **IDR (Indonesian Rupiah)** + or one of the supported foreign currencies if you need cross-rate + calculations. diff --git a/currency_rate_update_BI/readme/DESCRIPTION.rst b/currency_rate_update_BI/readme/DESCRIPTION.rst new file mode 100644 index 00000000..26b9efdf --- /dev/null +++ b/currency_rate_update_BI/readme/DESCRIPTION.rst @@ -0,0 +1,57 @@ +Adds **Bank Indonesia (BI)** as a currency rates provider for the +``currency_rate_update`` module. Exchange rates are fetched from +Bank Indonesia's official SOAP webservice (wsKursBI) using the +``getSubKursLokal3`` method. + +**Data Source:** Bank Indonesia — https://www.bi.go.id + +**Rate Type:** Kurs Transaksi BI (Transaction Rates — middle rate / kurs tengah) + +**Update Frequency:** Daily on business days (Mon–Fri, excluding Indonesian national holidays) + +**Supported Currencies (vs IDR):** + ++-----+----------------------------+ +| USD | United States Dollar | ++-----+----------------------------+ +| EUR | Euro | ++-----+----------------------------+ +| GBP | British Pound | ++-----+----------------------------+ +| JPY | Japanese Yen | ++-----+----------------------------+ +| SGD | Singapore Dollar | ++-----+----------------------------+ +| AUD | Australian Dollar | ++-----+----------------------------+ +| BND | Brunei Dollar | ++-----+----------------------------+ +| CAD | Canadian Dollar | ++-----+----------------------------+ +| CHF | Swiss Franc | ++-----+----------------------------+ +| CNY | Chinese Yuan | ++-----+----------------------------+ +| DKK | Danish Krone | ++-----+----------------------------+ +| HKD | Hong Kong Dollar | ++-----+----------------------------+ +| KRW | South Korean Won | ++-----+----------------------------+ +| MYR | Malaysian Ringgit | ++-----+----------------------------+ +| NOK | Norwegian Krone | ++-----+----------------------------+ +| NZD | New Zealand Dollar | ++-----+----------------------------+ +| SAR | Saudi Riyal | ++-----+----------------------------+ +| SEK | Swedish Krona | ++-----+----------------------------+ +| THB | Thai Baht | ++-----+----------------------------+ + +**Note:** This module is designed primarily for Indonesian companies +using **IDR (Indonesian Rupiah)** as their base currency. Cross-rate +calculations (e.g. USD base company wanting EUR rates) are supported +as long as the base currency is in the list above. diff --git a/currency_rate_update_BI/readme/USAGE.rst b/currency_rate_update_BI/readme/USAGE.rst new file mode 100644 index 00000000..15c15967 --- /dev/null +++ b/currency_rate_update_BI/readme/USAGE.rst @@ -0,0 +1,18 @@ +After configuration, currency rates are automatically updated based on +your schedule. To manually update rates: + +#. Go to *Invoicing > Configuration > Currency Rates Providers* +#. Select the Bank Indonesia provider +#. Click *Action > Update Rates Wizard* +#. Set the date range (From/To dates) +#. Click *Update* to fetch rates from Bank Indonesia + +To view the fetched rates: + +#. Go to *Invoicing > Configuration > Currencies* +#. Select a currency (e.g., USD) +#. Open the *Rates* tab to see historical exchange rates + +The rates are fetched from Bank Indonesia's official webservice at +https://www.bi.go.id/biwebservice/wskursbi.asmx and represent the +middle rate (average of buy/sell rates) for each currency pair. diff --git a/currency_rate_update_BI/static/description/icon.png b/currency_rate_update_BI/static/description/icon.png new file mode 100644 index 00000000..5617ecd7 Binary files /dev/null and b/currency_rate_update_BI/static/description/icon.png differ diff --git a/currency_rate_update_BI/static/description/index.html b/currency_rate_update_BI/static/description/index.html new file mode 100644 index 00000000..164c9688 --- /dev/null +++ b/currency_rate_update_BI/static/description/index.html @@ -0,0 +1,533 @@ + + + + + +Currency Rate Update: Bank Indonesia + + + +
+

Currency Rate Update: Bank Indonesia

+ + +

Beta License: AGPL-3 OCA/currency Translate me on Weblate Try me on Runboat

+

Adds Bank Indonesia (BI) as a currency rates provider for the +currency_rate_update module. Exchange rates are fetched from +Bank Indonesia’s official SOAP webservice (wsKursBI) using the +getSubKursLokal3 method.

+

Data Source: Bank Indonesia — https://www.bi.go.id

+

Rate Type: Kurs Transaksi BI (Transaction Rates — middle rate / kurs tengah)

+

Update Frequency: Daily on business days (Mon–Fri, excluding Indonesian national holidays)

+

Supported Currencies (vs IDR):

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
USDUnited States Dollar
EUREuro
GBPBritish Pound
JPYJapanese Yen
SGDSingapore Dollar
AUDAustralian Dollar
BNDBrunei Dollar
CADCanadian Dollar
CHFSwiss Franc
CNYChinese Yuan
DKKDanish Krone
HKDHong Kong Dollar
KRWSouth Korean Won
MYRMalaysian Ringgit
NOKNorwegian Krone
NZDNew Zealand Dollar
SARSaudi Riyal
SEKSwedish Krona
THBThai Baht
+

Note: This module is designed primarily for Indonesian companies +using IDR (Indonesian Rupiah) as their base currency. Cross-rate +calculations (e.g. USD base company wanting EUR rates) are supported +as long as the base currency is in the list above.

+

Table of contents

+ +
+

Configuration

+

To configure Bank Indonesia currency rates provider:

+
    +
  1. Go to Invoicing > Configuration > Settings
  2. +
  3. Ensure Automatic Currency Rates (OCA) is checked
  4. +
  5. Go to Invoicing > Configuration > Currency Rates Providers
  6. +
  7. Create a new provider with Provider: Bank Indonesia (Kurs Transaksi)
  8. +
  9. Select the currencies you want to update (USD, EUR, SGD, etc.)
  10. +
  11. Configure the update schedule (e.g., daily, weekly)
  12. +
  13. Save the provider
  14. +
+
+

Note

+

Your company’s base currency should be IDR (Indonesian Rupiah) +or one of the supported foreign currencies if you need cross-rate +calculations.

+
+
+
+

Usage

+

After configuration, currency rates are automatically updated based on +your schedule. To manually update rates:

+
    +
  1. Go to Invoicing > Configuration > Currency Rates Providers
  2. +
  3. Select the Bank Indonesia provider
  4. +
  5. Click Action > Update Rates Wizard
  6. +
  7. Set the date range (From/To dates)
  8. +
  9. Click Update to fetch rates from Bank Indonesia
  10. +
+

To view the fetched rates:

+
    +
  1. Go to Invoicing > Configuration > Currencies
  2. +
  3. Select a currency (e.g., USD)
  4. +
  5. Open the Rates tab to see historical exchange rates
  6. +
+

The rates are fetched from Bank Indonesia’s official webservice at +https://www.bi.go.id/biwebservice/wskursbi.asmx and represent the +middle rate (average of buy/sell rates) for each currency pair.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • PT Solusi Aglis Indonesia
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

hitrosol

+

This module is part of the OCA/currency project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/currency_rate_update_BI/tests/__init__.py b/currency_rate_update_BI/tests/__init__.py new file mode 100644 index 00000000..5bbf575f --- /dev/null +++ b/currency_rate_update_BI/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import test_currency_rate_update_BI diff --git a/currency_rate_update_BI/tests/test_currency_rate_update_BI.py b/currency_rate_update_BI/tests/test_currency_rate_update_BI.py new file mode 100644 index 00000000..8fb2f9f1 --- /dev/null +++ b/currency_rate_update_BI/tests/test_currency_rate_update_BI.py @@ -0,0 +1,600 @@ +# Copyright 2026 PT Solusi Aglis Indonesia (http://solusiaglis.co.id) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from io import BytesIO +from unittest.mock import patch + +from odoo import fields +from odoo.tests import common + +# Real XML response format from Bank Indonesia wsKursBI getSubKursLokal3 +# Confirmed from live BI webservice (diffgram format, ADO.NET DataSet) +SAMPLE_BI_XML_USD = b""" + + + + + + + + + + + + + + + + + + + + + + + + + + 962545 + 44 + 1.00 + 16741.87 + 16910.13 + 2026-02-06T00:00:00+07:00 + USD +
+ + 962310 + 44 + 1.00 + 16691.13 + 16858.88 + 2026-02-05T00:00:00+07:00 + USD +
+ + 962075 + 44 + 1.00 + 16693.12 + 16860.88 + 2026-02-04T00:00:00+07:00 + USD +
+ + 961840 + 44 + 1.00 + 16716.00 + 16884.00 + 2026-02-03T00:00:00+07:00 + USD +
+
+
+
""" + +SAMPLE_BI_XML_EUR = b""" + + + + + + + + + + + + + + + + + + + + + + + + + + 962550 + 44 + 1.00 + 17421.00 + 17605.00 + 2026-02-06T00:00:00+07:00 + EUR +
+ + 962315 + 44 + 1.00 + 17380.00 + 17560.00 + 2026-02-05T00:00:00+07:00 + EUR +
+ + 962080 + 44 + 1.00 + 17390.00 + 17570.00 + 2026-02-04T00:00:00+07:00 + EUR +
+ + 961845 + 44 + 1.00 + 17400.00 + 17580.00 + 2026-02-03T00:00:00+07:00 + EUR +
+
+
+
""" + +# Sample with nil_subkurslokal=100 (e.g. JPY - quoted per 100 units) +SAMPLE_BI_XML_JPY_NIL100 = b""" + + + + + + + + + + + + + + + + + 100.00 + 10700.00 + 10900.00 + 2026-02-06T00:00:00+07:00 + JPY +
+
+
+
""" + + +def _mock_urlopen(req, *args, **kwargs): + """Mock urlopen returning sample BI XML based on currency code in URL.""" + url_str = req.full_url if hasattr(req, "full_url") else str(req) + if "mts=USD" in url_str: + return BytesIO(SAMPLE_BI_XML_USD) + elif "mts=EUR" in url_str: + return BytesIO(SAMPLE_BI_XML_EUR) + elif "mts=JPY" in url_str: + return BytesIO(SAMPLE_BI_XML_JPY_NIL100) + return BytesIO( + b'' + b'' + b'' + ) + + +class TestResCurrencyRateProviderBI(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.Company = cls.env["res.company"] + cls.CurrencyRate = cls.env["res.currency.rate"] + cls.CurrencyRateProvider = cls.env["res.currency.rate.provider"] + + cls.today = fields.Date.today() + cls.idr_currency = cls.env.ref("base.IDR") + cls.usd_currency = cls.env.ref("base.USD") + cls.eur_currency = cls.env.ref("base.EUR") + cls.jpy_currency = cls.env.ref("base.JPY") + + cls.company = cls.Company.create( + {"name": "Test PT Indonesia", "currency_id": cls.idr_currency.id} + ) + cls.env.user.company_ids += cls.company + cls.env.company = cls.company + + cls.bi_provider = cls.CurrencyRateProvider.create( + { + "service": "BI", + "currency_ids": [ + (4, cls.usd_currency.id), + (4, cls.eur_currency.id), + ], + } + ) + cls.CurrencyRate.search([]).unlink() + + @patch( + "odoo.addons.currency_rate_update_BI.models" + ".res_currency_rate_provider_BI.urlopen", + side_effect=_mock_urlopen, + ) + def test_scheduled_update(self, mock_urlopen): + """Scheduled cron creates rates for USD and EUR.""" + self.bi_provider._scheduled_update() + rates = self.CurrencyRate.search([("provider_id", "=", self.bi_provider.id)]) + currencies = rates.mapped("currency_id") + self.assertIn(self.usd_currency, currencies) + self.assertIn(self.eur_currency, currencies) + + @patch( + "odoo.addons.currency_rate_update_BI.models" + ".res_currency_rate_provider_BI.urlopen", + side_effect=_mock_urlopen, + ) + def test_wizard_update(self, mock_urlopen): + """Wizard update creates rates for full date range (USD×4 + EUR×4 = 8).""" + from datetime import date + + wizard = ( + self.env["res.currency.rate.update.wizard"] + .with_context(default_provider_ids=[(6, False, self.bi_provider.ids)]) + .create( + { + "date_from": date(2026, 2, 3), + "date_to": date(2026, 2, 6), + } + ) + ) + wizard.action_update() + rates = self.CurrencyRate.search([("provider_id", "=", self.bi_provider.id)]) + self.assertEqual(len(rates), 8) + + @patch( + "odoo.addons.currency_rate_update_BI.models" + ".res_currency_rate_provider_BI.urlopen", + side_effect=_mock_urlopen, + ) + def test_rate_value_idr_base(self, mock_urlopen): + """Rate for USD on 2026-02-06: (16741.87+16910.13)/2 = 16826.0 IDR/USD. + Stored in Odoo as 1/16826.0 (foreign per 1 IDR base). + """ + from datetime import date + + wizard = ( + self.env["res.currency.rate.update.wizard"] + .with_context(default_provider_ids=[(6, False, self.bi_provider.ids)]) + .create( + { + "date_from": date(2026, 2, 6), + "date_to": date(2026, 2, 6), + } + ) + ) + wizard.action_update() + + usd_rate = self.CurrencyRate.search( + [ + ("provider_id", "=", self.bi_provider.id), + ("currency_id", "=", self.usd_currency.id), + ("name", "=", date(2026, 2, 6)), + ], + limit=1, + ) + self.assertTrue(usd_rate, "USD rate for 2026-02-06 should exist") + # (16741.87 + 16910.13) / 2 = 16826.0 IDR/USD → stored as 1/16826 + expected_rate = 1 / 16826.0 + self.assertAlmostEqual(usd_rate.rate, expected_rate, places=10) + + def test_parse_bi_response_usd(self): + """Parse real-format XML: 4 USD rows returned correctly.""" + rates = self.bi_provider._parse_bi_response(SAMPLE_BI_XML_USD, "USD") + self.assertEqual(len(rates), 4) + self.assertIn("2026-02-03", rates) + self.assertIn("2026-02-04", rates) + self.assertIn("2026-02-05", rates) + self.assertIn("2026-02-06", rates) + # 2026-02-06: (16741.87 + 16910.13) / 2 / 1 = 16826.0 + self.assertAlmostEqual(rates["2026-02-06"], 16826.0) + # 2026-02-03: (16716.00 + 16884.00) / 2 / 1 = 16800.0 + self.assertAlmostEqual(rates["2026-02-03"], 16800.0) + + def test_parse_bi_response_nil100(self): + """nil_subkurslokal=100: rate normalized to per-1-unit. + JPY: (10700 + 10900) / 2 / 100 = 108.0 IDR/JPY + """ + rates = self.bi_provider._parse_bi_response(SAMPLE_BI_XML_JPY_NIL100, "JPY") + self.assertEqual(len(rates), 1) + self.assertAlmostEqual(rates["2026-02-06"], 108.0) + + def test_parse_bi_response_empty(self): + """Empty DataSet returns empty dict without raising.""" + empty_xml = ( + b'' + b'' + b'' + b'' + b"" + ) + rates = self.bi_provider._parse_bi_response(empty_xml, "USD") + self.assertEqual(rates, {}) + + def test_supported_currencies(self): + """BI provider returns correct supported currencies list.""" + currencies = self.bi_provider._get_supported_currencies() + self.assertIn("USD", currencies) + self.assertIn("EUR", currencies) + self.assertIn("JPY", currencies) + self.assertIn("SGD", currencies) + self.assertIn("MYR", currencies) + self.assertNotIn("IDR", currencies) + + @patch( + "odoo.addons.currency_rate_update_BI.models" + ".res_currency_rate_provider_BI.urlopen", + side_effect=_mock_urlopen, + ) + def test_foreign_base_currency_cross_rate(self, mock_urlopen): + """Test cross-rate calculation for foreign base currency (USD base).""" + from datetime import date + + # Create USD-based company + usd_company = self.Company.create( + {"name": "Test US Company", "currency_id": self.usd_currency.id} + ) + bi_provider_usd = self.CurrencyRateProvider.with_company(usd_company).create( + { + "service": "BI", + "currency_ids": [(4, self.eur_currency.id)], + "company_id": usd_company.id, + } + ) + + wizard = ( + self.env["res.currency.rate.update.wizard"] + .with_context(default_provider_ids=[(6, False, bi_provider_usd.ids)]) + .with_company(usd_company) + .create( + { + "date_from": date(2026, 2, 6), + "date_to": date(2026, 2, 6), + } + ) + ) + wizard.action_update() + + eur_rate = self.CurrencyRate.search( + [ + ("provider_id", "=", bi_provider_usd.id), + ("currency_id", "=", self.eur_currency.id), + ("name", "=", date(2026, 2, 6)), + ], + limit=1, + ) + self.assertTrue(eur_rate, "EUR rate for USD base should exist") + # Cross rate: EUR/USD = IDR/USD ÷ IDR/EUR = 16826 ÷ 17513 ≈ 0.9608 + # IDR/USD = (16741.87 + 16910.13) / 2 = 16826 + # IDR/EUR = (17421 + 17605) / 2 = 17513 + expected_cross_rate = 16826.0 / 17513.0 + self.assertAlmostEqual(eur_rate.rate, expected_cross_rate, places=4) + + def test_unsupported_base_currency(self): + """Test error when base currency not supported by BI.""" + from datetime import date + + from odoo.exceptions import UserError + + # BRL (Brazilian Real) is not in BI_SUPPORTED_CURRENCIES + brl_currency = self.env.ref("base.BRL") + brl_company = self.Company.create( + {"name": "Test Brazil Company", "currency_id": brl_currency.id} + ) + bi_provider_brl = self.CurrencyRateProvider.with_company(brl_company).create( + { + "service": "BI", + "currency_ids": [(4, self.usd_currency.id)], + "company_id": brl_company.id, + } + ) + + with self.assertRaises(UserError) as cm: + bi_provider_brl._obtain_rates( + "BRL", ["USD"], date(2026, 2, 6), date(2026, 2, 6) + ) + self.assertIn("not supported", str(cm.exception)) + + @patch( + "odoo.addons.currency_rate_update_BI.models" + ".res_currency_rate_provider_BI.urlopen" + ) + def test_connection_error(self, mock_urlopen): + """Test handling of connection error.""" + from datetime import date + from urllib.error import URLError + + from odoo.exceptions import UserError + + mock_urlopen.side_effect = URLError("Connection timeout") + + with self.assertRaises(UserError) as cm: + self.bi_provider._obtain_rates( + "IDR", ["USD"], date(2026, 2, 6), date(2026, 2, 6) + ) + self.assertIn("Cannot connect", str(cm.exception)) + + def test_parse_missing_fields(self): + """Test parsing XML with missing beli/jual fields.""" + missing_fields_xml = b""" + + + + + 1.00 + 2026-02-06T00:00:00+07:00 + USD +
+
+
+
""" + rates = self.bi_provider._parse_bi_response(missing_fields_xml, "USD") + self.assertEqual(rates, {}) + + def test_parse_invalid_xml(self): + """Test parsing invalid XML returns empty dict.""" + invalid_xml = b"xml" + rates = self.bi_provider._parse_bi_response(invalid_xml, "USD") + self.assertEqual(rates, {}) + + def test_parse_missing_date(self): + """Test parsing XML with missing date field.""" + missing_date_xml = b""" + + + + + 1.00 + 16741.87 + 16910.13 + USD +
+
+
+
""" + rates = self.bi_provider._parse_bi_response(missing_date_xml, "USD") + self.assertEqual(rates, {}) + + def test_get_currencies_to_fetch_idr_base(self): + """Test _get_currencies_to_fetch with IDR base.""" + fetch = self.bi_provider._get_currencies_to_fetch("IDR", ["USD", "EUR", "GBP"]) + self.assertIn("USD", fetch) + self.assertIn("EUR", fetch) + self.assertIn("GBP", fetch) + self.assertNotIn("IDR", fetch) + + def test_get_currencies_to_fetch_foreign_base(self): + """Test _get_currencies_to_fetch with USD base adds USD to fetch list.""" + fetch = self.bi_provider._get_currencies_to_fetch("USD", ["EUR", "GBP"]) + self.assertIn("USD", fetch) # Added for cross-rate calculation + self.assertIn("EUR", fetch) + self.assertIn("GBP", fetch) + + def test_convert_idr_base_rates(self): + """Test _convert_idr_base_rates returns inverted format.""" + rates_by_date = {"2026-02-06": {"USD": 16826.0}} + result = self.bi_provider._convert_idr_base_rates(rates_by_date) + self.assertEqual(result["2026-02-06"]["USD"], {"inverted": 16826.0}) + + def test_convert_foreign_base_rates(self): + """Test _convert_foreign_base_rates calculates cross rates.""" + rates_by_date = {"2026-02-06": {"USD": 16826.0, "EUR": 17513.0}} + result = self.bi_provider._convert_foreign_base_rates(rates_by_date, "USD") + # EUR/USD = 16826 / 17513 + expected = 16826.0 / 17513.0 + self.assertAlmostEqual(result["2026-02-06"]["EUR"], expected, places=4) + + def test_convert_foreign_base_rates_missing_base(self): + """Test _convert_foreign_base_rates skips dates without base currency.""" + rates_by_date = { + "2026-02-06": {"EUR": 17513.0}, # Missing USD + "2026-02-07": {"USD": 16826.0, "EUR": 17513.0}, + } + result = self.bi_provider._convert_foreign_base_rates(rates_by_date, "USD") + self.assertNotIn("2026-02-06", result) # Skipped + self.assertIn("2026-02-07", result) + + @patch( + "odoo.addons.currency_rate_update_BI.models" + ".res_currency_rate_provider_BI.urlopen", + side_effect=_mock_urlopen, + ) + def test_foreign_base_with_idr_selected(self, mock_urlopen): + """Test that IDR can be selected and has correct rate when base is USD.""" + from datetime import date + + # Create USD-based company + usd_company = self.Company.create( + {"name": "Test US Company IDR", "currency_id": self.usd_currency.id} + ) + + bi_provider_usd = self.CurrencyRateProvider.with_company(usd_company).create( + { + "service": "BI", + "currency_ids": [ + (4, self.idr_currency.id), + (4, self.eur_currency.id), + ], + "company_id": usd_company.id, + } + ) + + wizard = ( + self.env["res.currency.rate.update.wizard"] + .with_context(default_provider_ids=[(6, False, bi_provider_usd.ids)]) + .with_company(usd_company) + .create( + { + "date_from": date(2026, 2, 6), + "date_to": date(2026, 2, 6), + } + ) + ) + wizard.action_update() + + # Check IDR rate was created + idr_rate = self.CurrencyRate.search( + [ + ("provider_id", "=", bi_provider_usd.id), + ("currency_id", "=", self.idr_currency.id), + ("name", "=", date(2026, 2, 6)), + ], + limit=1, + ) + self.assertTrue(idr_rate, "IDR rate for USD base should exist") + + # IDR/USD rate from BI: (16741.87 + 16910.13) / 2 = 16826.0 + expected_rate = 16826.0 + self.assertAlmostEqual(idr_rate.rate, expected_rate, places=1) + + # Also verify EUR cross-rate still works + eur_rate = self.CurrencyRate.search( + [ + ("provider_id", "=", bi_provider_usd.id), + ("currency_id", "=", self.eur_currency.id), + ("name", "=", date(2026, 2, 6)), + ], + limit=1, + ) + self.assertTrue(eur_rate, "EUR rate should also exist") + expected_eur_rate = 16826.0 / 17513.0 + self.assertAlmostEqual(eur_rate.rate, expected_eur_rate, places=4) + + def test_supported_currencies_idr_base(self): + """Test that IDR is NOT in supported currencies when base is IDR.""" + currencies = self.bi_provider._get_supported_currencies() + self.assertNotIn("IDR", currencies) + + def test_supported_currencies_usd_base(self): + """Test that IDR IS in supported currencies when base is USD.""" + usd_company = self.Company.create( + {"name": "Test US Company", "currency_id": self.usd_currency.id} + ) + bi_provider_usd = self.CurrencyRateProvider.with_company(usd_company).create( + { + "service": "BI", + "currency_ids": [], + "company_id": usd_company.id, + } + ) + currencies = bi_provider_usd._get_supported_currencies() + self.assertIn("IDR", currencies) + self.assertIn("USD", currencies) + self.assertIn("EUR", currencies) diff --git a/setup/currency_rate_update_BI/odoo/addons/currency_rate_update_BI b/setup/currency_rate_update_BI/odoo/addons/currency_rate_update_BI new file mode 120000 index 00000000..928614a3 --- /dev/null +++ b/setup/currency_rate_update_BI/odoo/addons/currency_rate_update_BI @@ -0,0 +1 @@ +../../../../currency_rate_update_BI \ No newline at end of file diff --git a/setup/currency_rate_update_BI/setup.py b/setup/currency_rate_update_BI/setup.py new file mode 100644 index 00000000..28c57bb6 --- /dev/null +++ b/setup/currency_rate_update_BI/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)