diff --git a/product_cost_usd/README.rst b/product_cost_usd/README.rst new file mode 100644 index 00000000000..1dbdedaed6f --- /dev/null +++ b/product_cost_usd/README.rst @@ -0,0 +1,38 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +Product Price Cost in USD: +========================== + +This module adds a field to handle cost in USD on products. +The approach is to have the USD as the base currency, +so that the sale prices in company currency can be +kept up to date through the exchange rate. + +Features: +--------- +- New 'Cost in USD' field on the Product form. +- Validate 'Cost in USD' so that it is not less than the list price of the + supplier. +- Avoid save a 'Cost in USD' when product does not has assigned a supplier + with price in USD. +- Allowed pricelist computation based on Cost in USD. +- Added unit tests to validate constrains and pricelists computation. +- Compatibility with the module `sale_margin + `_ + + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. + +Credits +======= + +Contributors +------------ + +* Jose Suniaga diff --git a/product_cost_usd/__init__.py b/product_cost_usd/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/product_cost_usd/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/product_cost_usd/__manifest__.py b/product_cost_usd/__manifest__.py new file mode 100644 index 00000000000..28c6c1447d6 --- /dev/null +++ b/product_cost_usd/__manifest__.py @@ -0,0 +1,23 @@ +{ + "name": "Product Price Cost in USD", + "summary": """ +This module adds the field Cost in USD to the Product form. + """, + "version": "19.0.1.0.0", + "author": "Vauxoo", + "category": "Sales/Sales", + "website": "https://vauxoo.com", + "license": "LGPL-3", + "depends": [ + "sale_margin", + "sale_stock_margin", + ], + "demo": [ + "demo/product_pricelist_demo.xml", + ], + "data": [ + "data/res_currency_data.xml", + "views/product_template_views.xml", + ], + "installable": True, +} diff --git a/product_cost_usd/data/res_currency_data.xml b/product_cost_usd/data/res_currency_data.xml new file mode 100644 index 00000000000..d92a0d495cd --- /dev/null +++ b/product_cost_usd/data/res_currency_data.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/product_cost_usd/demo/product_pricelist_demo.xml b/product_cost_usd/demo/product_pricelist_demo.xml new file mode 100644 index 00000000000..1445ad6b941 --- /dev/null +++ b/product_cost_usd/demo/product_pricelist_demo.xml @@ -0,0 +1,14 @@ + + + + Pricelist 15% USD + + + + + formula + standard_price_usd + -15 + + + diff --git a/product_cost_usd/i18n/es.po b/product_cost_usd/i18n/es.po new file mode 100644 index 00000000000..b7c9b80fa2b --- /dev/null +++ b/product_cost_usd/i18n/es.po @@ -0,0 +1,113 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * product_cost_usd +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-05-06 21:00+0000\n" +"PO-Revision-Date: 2026-05-06 21: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: \n" +"Plural-Forms: \n" + +#. module: product_cost_usd +#: model:ir.model.fields,help:product_cost_usd.field_product_pricelist_item__base +msgid "" +"Base price for computation.\n" +"Sales Price: The base price will be the Sales Price.\n" +"Cost Price: The base price will be the cost price.\n" +"Other Pricelist: Computation of the base price based on another Pricelist." +msgstr "" +"Precio base para el cálculo.\n" +"Precio de venta: El precio base será el precio de venta.\n" +"Precio de coste: El precio base será el precio de coste.\n" +"Otra lista de precios: Cálculo del precio base de acuerdo con otra lista de precios." + +#. module: product_cost_usd +#: model:ir.model.fields,field_description:product_cost_usd.field_product_pricelist_item__base +msgid "Based on" +msgstr "Basado en" + +#. module: product_cost_usd +#: model:ir.model.fields,field_description:product_cost_usd.field_product_product__standard_price_usd +#: model:ir.model.fields,field_description:product_cost_usd.field_product_template__standard_price_usd +#: model:ir.model.fields.selection,name:product_cost_usd.selection__product_pricelist_item__base__standard_price_usd +msgid "Cost in USD" +msgstr "Costo en USD" + +#. module: product_cost_usd +#: model:ir.model.fields,field_description:product_cost_usd.field_product_product__currency_usd_id +#: model:ir.model.fields,field_description:product_cost_usd.field_product_template__currency_usd_id +msgid "Currency USD" +msgstr "Moneda USD" + +#. module: product_cost_usd +#: model:ir.model.fields,field_description:product_cost_usd.field_product_pricelist_item__display_name +#: model:ir.model.fields,field_description:product_cost_usd.field_product_template__display_name +#: model:ir.model.fields,field_description:product_cost_usd.field_sale_order_line__display_name +msgid "Display Name" +msgstr "Nombre mostrado" + +#. module: product_cost_usd +#: model:ir.model.fields,field_description:product_cost_usd.field_product_pricelist_item__id +#: model:ir.model.fields,field_description:product_cost_usd.field_product_template__id +#: model:ir.model.fields,field_description:product_cost_usd.field_sale_order_line__id +msgid "ID" +msgstr "" + +#. module: product_cost_usd +#: model:ir.model.fields,help:product_cost_usd.field_product_product__standard_price_usd +#: model:ir.model.fields,help:product_cost_usd.field_product_template__standard_price_usd +msgid "Price cost of the product in USD currency" +msgstr "Precio coste del producto en moneda USD" + +#. module: product_cost_usd +#: model:ir.model,name:product_cost_usd.model_product_pricelist_item +msgid "Pricelist Rule" +msgstr "Regla de la lista de precios" + +#. module: product_cost_usd +#: model:ir.model,name:product_cost_usd.model_product_template +msgid "Product" +msgstr "Producto" + +#. module: product_cost_usd +#: model:ir.model,name:product_cost_usd.model_sale_order_line +msgid "Sales Order Line" +msgstr "Línea de pedido de venta" + +#. module: product_cost_usd +#: model:ir.model.fields,help:product_cost_usd.field_product_product__currency_usd_id +#: model:ir.model.fields,help:product_cost_usd.field_product_template__currency_usd_id +msgid "Technical field to show the price fields as USD in the products" +msgstr "" +"Campo técnico para mostrar los campos de precio como USD en los productos" + +#. module: product_cost_usd +#. odoo-python +#: code:addons/product_cost_usd/models/product_template.py:0 +msgid "" +"You cannot create or modify a product if the cost in USD is less than the supplier list price.\n" +"\n" +"- Supplier list price = %s\n" +"- Cost in USD = %s" +msgstr "" +"No puede crear o modificar un producto si el costo en USD es menor que el precio del proveedor.\n" +"\n" +"- Precio del proveedor = %s\n" +"- Costo en USD = %s" + +#. module: product_cost_usd +#. odoo-python +#: code:addons/product_cost_usd/models/product_template.py:0 +msgid "" +"You must have at least one supplier with price in USD before assigning a " +"Cost in USD" +msgstr "" +"Debe tener al menos un proveedor con precio en USD antes de asignar un Costo" +" en USD al producto" diff --git a/product_cost_usd/models/__init__.py b/product_cost_usd/models/__init__.py new file mode 100644 index 00000000000..33a5ae818c4 --- /dev/null +++ b/product_cost_usd/models/__init__.py @@ -0,0 +1,3 @@ +from . import product_template +from . import product_pricelist_item +from . import sale_order_line diff --git a/product_cost_usd/models/product_pricelist_item.py b/product_cost_usd/models/product_pricelist_item.py new file mode 100644 index 00000000000..b95b72abf88 --- /dev/null +++ b/product_cost_usd/models/product_pricelist_item.py @@ -0,0 +1,42 @@ +from odoo import fields, models + + +class PricelistItem(models.Model): + _inherit = "product.pricelist.item" + + base = fields.Selection( + selection_add=[("standard_price_usd", "Cost in USD")], ondelete={"standard_price_usd": "set default"} + ) + + def _compute_base_price(self, product, quantity, uom, date, currency, **kwargs): + """Compute the base price for a pricelist item based on the USD standard price. + + This method overrides the native base price computation to inject our custom + 'standard_price_usd' base. When this specific base is selected in the pricelist + item, the method performs the following pipeline: + 1. Retrieves the raw USD cost from the product (`standard_price_usd`). + 2. Adjusts the price according to the requested Unit of Measure (UoM) to ensure + correct pricing for different quantities (e.g., dozens vs. units). + 3. Converts the adjusted USD price into the pricelist's target currency. + + By returning the exact converted base price at this stage, we allow Odoo's + native pricing engine to seamlessly handle subsequent operations like discounts, + surcharges, and financial rounding rules. + """ + if self.base != "standard_price_usd": + return super()._compute_base_price(product, quantity, uom, date, currency, **kwargs) + target_currency = currency or self.currency_id + usd_currency = self.env.ref("base.USD", raise_if_not_found=False) + + # 1. Retrieve the raw USD cost from the product + price = product.standard_price_usd + + # 2. Unit of Measure conversion + if product.uom_id != uom: + price = product.uom_id._compute_price(price, uom) + + # 3. Convert from USD to the target pricelist currency + # We set round=False to allow Odoo's native engine to apply its own rounding rules at the end + if usd_currency and target_currency != usd_currency: + price = usd_currency._convert(price, target_currency, self.env.company, date, round=False) + return price diff --git a/product_cost_usd/models/product_template.py b/product_cost_usd/models/product_template.py new file mode 100644 index 00000000000..91642e9451b --- /dev/null +++ b/product_cost_usd/models/product_template.py @@ -0,0 +1,55 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools import float_compare + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + currency_usd_id = fields.Many2one( + "res.currency", + string="Currency USD", + compute="_compute_currency_usd_id", + help="Technical field to show the price fields as USD in the products", + ) + standard_price_usd = fields.Float( + string="Cost in USD", + digits="Product Price", + help="Price cost of the product in USD currency", + ) + + def _compute_currency_usd_id(self): + currency_usd = self.env.ref("base.USD") + for product in self: + product.currency_usd_id = currency_usd + + @api.constrains("standard_price_usd", "seller_ids") + def check_cost_and_price(self): + """Validate 'Cost in USD' usability. + + Usability conditions: + - Before set a 'Cost in USD' in a product at least one supplier should + have price in USD. + - The Cost in USD cannot be less than supplier price. + """ + usd_currency = self.env.ref("base.USD") + prec = self.env["decimal.precision"].precision_get("Product Price") + for product in self: + usd_seller = product.seller_ids.filtered(lambda x: x.currency_id == usd_currency)[:1] + list_price = usd_seller.price + standard_price_usd = product.standard_price_usd + if not usd_seller and float_compare(standard_price_usd, 0, precision_digits=prec) > 0: + raise ValidationError( + self.env._("You must have at least one supplier with price in USD before assigning a Cost in USD") + ) + if usd_seller and float_compare(list_price, standard_price_usd, precision_digits=prec) > 0: + raise ValidationError( + self.env._( + "You cannot create or modify a product if the cost in USD" + " is less than the supplier list price.\n\n" + "- Supplier list price = %s\n" + "- Cost in USD = %s", + list_price, + standard_price_usd, + ) + ) diff --git a/product_cost_usd/models/sale_order_line.py b/product_cost_usd/models/sale_order_line.py new file mode 100644 index 00000000000..e8ec8d607d3 --- /dev/null +++ b/product_cost_usd/models/sale_order_line.py @@ -0,0 +1,37 @@ +from odoo import api, models + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + @api.depends("product_id", "company_id", "currency_id", "product_uom_id") + def _compute_purchase_price(self): + """Inherited to recalculate purchase price when pricelist item is + based on cost in USD. + """ + res = super()._compute_purchase_price() + for line in self: + line = line.with_company(line.company_id) + product = line.product_id + if not product: + continue + pricelist = line.order_id.pricelist_id + date = line.order_id.date_order + price_rule = pricelist._compute_price_rule( + products=product, + quantity=1.0, + currency=pricelist.currency_id, + uom=line.product_uom_id, + date=date, + ) + _price, rule = price_rule.get(product.id, (0.0, False)) + suitable_rule_id = self.env["product.pricelist.item"].browse(rule) + if suitable_rule_id.base != "standard_price_usd": + continue + currency_usd = self.env.ref("base.USD") + to_cur = pricelist.currency_id + purchase_price = product.standard_price_usd + if line.product_uom_id != product.uom_id: + purchase_price = product.uom_id._compute_price(purchase_price, line.product_uom_id) + line.purchase_price = currency_usd._convert(purchase_price, to_cur, line.company_id, date, round=False) + return res diff --git a/product_cost_usd/static/description/icon.png b/product_cost_usd/static/description/icon.png new file mode 100644 index 00000000000..58dd9665bc2 Binary files /dev/null and b/product_cost_usd/static/description/icon.png differ diff --git a/product_cost_usd/static/description/index.html b/product_cost_usd/static/description/index.html new file mode 100644 index 00000000000..7a9fcd414f7 --- /dev/null +++ b/product_cost_usd/static/description/index.html @@ -0,0 +1,67 @@ + + + +
+
+

+ Product Price Cost in USD +

+

+ This module adds a field to handle cost in USD on products. + The approach is to have the USD as the base currency, + so that the sale prices in company currency can be + kept up to date through the exchange rate. +

+

Features:

+
    +
  • New 'Cost in USD' field on the Product form.
  • +
  • Validate 'Cost in USD' so that it is not less than the list price + of the supplier.
  • +
  • Avoid save a 'Cost in USD' when product does not has assigned a + supplier with price in USD.
  • +
  • Allowed pricelist computation based on Cost in USD.
  • +
  • Added unit tests to validate constrains and pricelists + computation.
  • +
  • Compatibility with the module `sale_margin`
  • +
+
+
+
+
+
+

Do you need help?

+

+ Let's offer you the best services! +

+

+ Contact us by our official channels. +

+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+ + + + +
+
+
+
diff --git a/product_cost_usd/tests/__init__.py b/product_cost_usd/tests/__init__.py new file mode 100644 index 00000000000..c7ce23a0291 --- /dev/null +++ b/product_cost_usd/tests/__init__.py @@ -0,0 +1 @@ +from . import test_standard_price_usd diff --git a/product_cost_usd/tests/test_standard_price_usd.py b/product_cost_usd/tests/test_standard_price_usd.py new file mode 100644 index 00000000000..985d3642803 --- /dev/null +++ b/product_cost_usd/tests/test_standard_price_usd.py @@ -0,0 +1,145 @@ +from odoo import Command +from odoo.exceptions import ValidationError +from odoo.tests import Form, TransactionCase, tagged +from odoo.tools import float_compare + + +@tagged("post_install", "-at_install", "sale") +class TestStandardPriceUsd(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.mxn = cls.env.ref("base.MXN") + cls.usd = cls.env.ref("base.USD") + cls.partner = cls.env["res.partner"].create({"name": "Test Partner"}) + cls.env.user.write({"group_ids": [Command.link(cls.env.ref("product.group_product_pricelist").id)]}) + # Get or create the unit UOM + cls.product_uom = cls.env.ref("uom.product_uom_unit") + if not cls.product_uom: + cls.product_uom = cls.env["uom.uom"].create( + { + "name": "Unit", + } + ) + cls.product = cls.env["product.product"].create( + { + "name": "Test Product", + "uom_id": cls.product_uom.id, + "type": "consu", + "standard_price": 876.0, + "list_price": 885.0, + } + ) + + # Create supplier for product + cls.env["product.supplierinfo"].create( + { + "product_tmpl_id": cls.product.product_tmpl_id.id, + "partner_id": cls.partner.id, + "price": 876.0, + "currency_id": cls.usd.id, + } + ) + + # Create pricelist with USD base cost strategy + cls.pricelist_15_usd = cls.env["product.pricelist"].create( + { + "name": "Pricelist 15% USD", + "currency_id": cls.usd.id, + "item_ids": [ + Command.create( + { + "applied_on": "1_product", + "product_id": cls.product.id, + "base": "standard_price_usd", + "compute_price": "formula", + "price_discount": -15, + } + ) + ], + } + ) + cls.pricelist_15_mxn = cls.pricelist_15_usd.copy({"name": "Pricelist 15% MXN", "currency_id": cls.mxn.id}) + cls.pricelist = cls.env["product.pricelist"].create({"name": "Pricelist Demo"}) + + def create_sale_order(self, product=None, partner=None, pricelist=None, **line_kwargs): + if partner is None: + partner = self.partner + + order = Form(self.env["sale.order"]) + order.partner_id = partner + if pricelist: + order.pricelist_id = pricelist + with order.order_line.new() as line: + line.product_id = product + line.product_uom_qty = 1 + return order.save() + + def set_standard_price_usd(self, price): + self.assertTrue(self.product.seller_ids) + self.product.seller_ids[0].write({"currency_id": self.usd.id}) + self.product.write({"standard_price_usd": price}) + + def test_01_usd_pricelist(self): + """Test USD pricelist based on cost in USD.""" + self.set_standard_price_usd(880) + product = self.product.with_context(pricelist=self.pricelist_15_usd.id) + expected_price = self.usd.round(product.standard_price_usd * 1.15) + product_price = product.get_contextual_price() + self.assertEqual( + float_compare(product_price, expected_price, precision_digits=2), + 0, + "Product price should be %s" % product_price, + ) + + def test_02_mxn_pricelist(self): + """Test a MXN pricelist based on cost in USD.""" + self.set_standard_price_usd(880) + product = self.product.with_context(pricelist=self.pricelist_15_mxn.id) + mxn_rate = self.mxn.rate / self.usd.rate + expected_price = self.mxn.round((product.standard_price_usd * 1.15) * mxn_rate) + product_price = product.get_contextual_price() + self.assertEqual( + float_compare(product_price, expected_price, precision_digits=2), + 0, + "Product price should be %s" % product_price, + ) + + def test_03_constraint_check_cost_no_seller(self): + """Test constraint check_cost_and_price.""" + self.product.seller_ids = False + with self.assertRaisesRegex(ValidationError, "You must have at least one supplier with price in USD"): + self.product.write({"standard_price_usd": 880}) + + def test_04_constraint_check_cost(self): + """Test constraint check_cost_and_price.""" + with self.assertRaisesRegex(ValidationError, "You cannot create or modify a product if the cost in USD"): + self.set_standard_price_usd(1) + + def test_05_sale_margin(self): + """Test the sale margin module using a pricelist with cost in USD.""" + self.set_standard_price_usd(880) + # Create a sale order for product Graphics Card. + sale_order = self.create_sale_order(product=self.product, pricelist=self.pricelist_15_mxn) + # Confirm the sale order. + sale_order.action_confirm() + # Verify that margin field gets bind with the value. + mxn_rate = self.mxn.rate / self.usd.rate + expected_price = self.mxn.round((self.product.standard_price_usd * 1.15) * mxn_rate) + expected_cost = self.mxn.round(self.product.standard_price_usd * mxn_rate) + margin = self.mxn.round(expected_price - expected_cost) + self.assertEqual( + float_compare(sale_order.margin, margin, precision_digits=2), 0, "Sale order margin should be %s" % margin + ) + + def test_06_sale_margin_normal(self): + """Test the sale margin module using a pricelist without cost in + USD. + """ + # Create a sale order for product Graphics Card. + sale_order = self.create_sale_order(product=self.product, pricelist=self.pricelist) + # Confirm the sale order. + sale_order.action_confirm() + # Verify that margin field gets bind with the value. + msg = "Sale order margin should be 9.0" + self.assertEqual(sale_order.margin, 9.0, msg) diff --git a/product_cost_usd/views/product_template_views.xml b/product_cost_usd/views/product_template_views.xml new file mode 100644 index 00000000000..8bf39de0ef6 --- /dev/null +++ b/product_cost_usd/views/product_template_views.xml @@ -0,0 +1,18 @@ + + + + product.template.form.view.inherit + product.template + + + + + + + + +