Pricing now follows website_sale, which includes the website's `Display Product Prices`. That setting defaults to tax-excluded in 18.0, so a website nobody configured shows Eskaera prices without VAT -- correct behaviour, but easy to read as a bug when the members are consumers and expect the price they will pay. The setting is a related field website_sale already exposes, so it now appears in the Eskaera block too, right where the co-op switches the feature on, rather than only under eCommerce. Tests cover both values on the shop and the listing paths, so the tax-excluded case is a decision on record instead of a surprise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
341 lines
13 KiB
Python
341 lines
13 KiB
Python
# Copyright 2025 Criptomart
|
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
|
|
|
"""Pricing that is delegated to website_sale instead of reimplemented.
|
|
|
|
Three things this pins down:
|
|
|
|
- Eskaera prices with the standard website pricelist. The addon no longer
|
|
carries a pricelist setting of its own, nor overrides how the website
|
|
resolves one, so `/shop` and Eskaera cannot quote different prices.
|
|
- taxes are applied by `product.template._apply_taxes_to_price`, so a fiscal
|
|
position that remaps a tax-included tax rebases the price -- the step this
|
|
addon used to skip.
|
|
- a page of products costs one pricelist resolution, not one per product.
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
from odoo.tests import tagged
|
|
from odoo.tests.common import TransactionCase
|
|
|
|
from ..controllers.website_sale import AplicoopWebsiteSale
|
|
|
|
REQUEST_PATCH_TARGET = (
|
|
"odoo.addons.website_sale_aplicoop.controllers.website_sale.request"
|
|
)
|
|
|
|
|
|
class PricingDelegationCommon:
|
|
"""A company, a website and a tax-included product to price."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.controller = AplicoopWebsiteSale()
|
|
self.company = self.env["res.company"].create({"name": "Pricing Deleg Co"})
|
|
self.website = self.env.ref("website.default_website").sudo()
|
|
self.website.write(
|
|
{
|
|
"company_id": self.company.id,
|
|
"show_line_subtotals_tax_selection": "tax_included",
|
|
}
|
|
)
|
|
self.tax_group = self.env["account.tax.group"].create(
|
|
{"name": "IVA Deleg", "company_id": self.company.id}
|
|
)
|
|
self.country_es = self.env.ref("base.es")
|
|
|
|
def _create_pricelist(self, name, percent=None):
|
|
vals = {"name": name, "company_id": self.company.id}
|
|
if percent is not None:
|
|
vals["item_ids"] = [
|
|
(
|
|
0,
|
|
0,
|
|
{
|
|
"compute_price": "percentage",
|
|
"percent_price": percent,
|
|
"applied_on": "3_global",
|
|
},
|
|
)
|
|
]
|
|
return self.env["product.pricelist"].create(vals)
|
|
|
|
def _create_tax(self, name, amount, included=False):
|
|
return self.env["account.tax"].create(
|
|
{
|
|
"name": name,
|
|
"amount": amount,
|
|
"amount_type": "percent",
|
|
"type_tax_use": "sale",
|
|
"price_include_override": (
|
|
"tax_included" if included else "tax_excluded"
|
|
),
|
|
"company_id": self.company.id,
|
|
"country_id": self.country_es.id,
|
|
"tax_group_id": self.tax_group.id,
|
|
}
|
|
)
|
|
|
|
def _create_product(self, name, list_price, tax=None):
|
|
return self.env["product.product"].create(
|
|
{
|
|
"name": name,
|
|
"type": "consu",
|
|
"list_price": list_price,
|
|
"is_published": True,
|
|
"sale_ok": True,
|
|
"company_id": self.company.id,
|
|
"taxes_id": [(6, 0, [tax.id])] if tax else False,
|
|
}
|
|
)
|
|
|
|
|
|
@tagged("post_install", "-at_install")
|
|
class TestStandardPricelistResolution(PricingDelegationCommon, TransactionCase):
|
|
"""Eskaera prices with the website pricelist, like the rest of the shop."""
|
|
|
|
def test_no_aplicoop_pricelist_setting_is_left(self):
|
|
"""The old per-addon pricelist setting is gone for good.
|
|
|
|
It used to let Eskaera quote a different pricelist from `/shop`, which
|
|
is exactly the drift this addon should not introduce.
|
|
"""
|
|
self.assertNotIn(
|
|
"aplicoop_pricelist_id", self.env["res.config.settings"]._fields
|
|
)
|
|
|
|
def test_website_resolution_is_left_untouched(self):
|
|
"""`_get_current_pricelist` is core's; the addon does not override it."""
|
|
website_cls = type(self.env["website"])
|
|
|
|
self.assertNotIn("_get_current_pricelist", website_cls.__dict__)
|
|
self.assertEqual(
|
|
self.website.pricelist_id, self.website._get_current_pricelist()
|
|
)
|
|
|
|
def test_the_controller_quotes_the_website_pricelist(self):
|
|
"""The Eskaera helper hands back exactly what the website resolved."""
|
|
expected = self._create_pricelist("Website PL", percent=15.0)
|
|
request_mock = SimpleNamespace(
|
|
env=self.env,
|
|
website=SimpleNamespace(pricelist_id=expected),
|
|
)
|
|
|
|
with patch(REQUEST_PATCH_TARGET, request_mock):
|
|
resolved = self.controller._resolve_pricelist()
|
|
|
|
self.assertEqual(resolved, expected)
|
|
|
|
|
|
@tagged("post_install", "-at_install")
|
|
class TestTaxDisplayFollowsTheWebsite(PricingDelegationCommon, TransactionCase):
|
|
"""Displayed prices honour the website's `Display Product Prices`.
|
|
|
|
Eskaera used to hardcode the tax-included display and ignore the setting.
|
|
Now it follows website_sale, which means a website left on the Odoo default
|
|
(`tax_excluded`) shows prices without VAT -- correct, but surprising enough
|
|
to be worth pinning down here.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.pricelist = self._create_pricelist("Display PL")
|
|
self.tax_21 = self._create_tax("IVA 21%", 21.0)
|
|
self.product = self._create_product("Display Product", 100.0, self.tax_21)
|
|
|
|
def _price(self):
|
|
return self.controller._get_pricing_info(
|
|
self.product, self.pricelist, quantity=1.0
|
|
)
|
|
|
|
def test_gross_price_on_a_tax_included_website(self):
|
|
"""A consumer-facing website quotes what the member pays."""
|
|
self.website.show_line_subtotals_tax_selection = "tax_included"
|
|
|
|
pricing = self._price()
|
|
|
|
self.assertAlmostEqual(pricing["price"], 121.0, places=2)
|
|
self.assertAlmostEqual(pricing["price_unit"], 100.0, places=2)
|
|
self.assertTrue(pricing["tax_included"])
|
|
|
|
def test_net_price_on_a_tax_excluded_website(self):
|
|
"""A website set to tax-excluded shows the net price."""
|
|
self.website.show_line_subtotals_tax_selection = "tax_excluded"
|
|
|
|
pricing = self._price()
|
|
|
|
self.assertAlmostEqual(pricing["price"], 100.0, places=2)
|
|
self.assertAlmostEqual(pricing["price_unit"], 100.0, places=2)
|
|
self.assertFalse(pricing["tax_included"])
|
|
|
|
def test_the_setting_is_what_moves_the_price(self):
|
|
"""Nothing but the website setting changes between the two."""
|
|
self.website.show_line_subtotals_tax_selection = "tax_included"
|
|
gross = self._price()["price"]
|
|
|
|
self.website.show_line_subtotals_tax_selection = "tax_excluded"
|
|
net = self._price()["price"]
|
|
|
|
self.assertAlmostEqual(gross - net, 21.0, places=2)
|
|
|
|
def test_batched_listing_follows_the_same_setting(self):
|
|
"""The listing path must not disagree with the single-product one."""
|
|
self.website.show_line_subtotals_tax_selection = "tax_excluded"
|
|
|
|
batched = self.controller._compute_price_info(self.product, self.pricelist)
|
|
|
|
self.assertAlmostEqual(batched[self.product.id]["price"], 100.0, places=2)
|
|
self.assertFalse(batched[self.product.id]["tax_included"])
|
|
|
|
|
|
@tagged("post_install", "-at_install")
|
|
class TestTaxIncludedFiscalPosition(PricingDelegationCommon, TransactionCase):
|
|
"""A remapped tax-included tax must rebase the displayed price."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.pricelist = self._create_pricelist("Plain PL")
|
|
self.tax_21_incl = self._create_tax("IVA 21% incl", 21.0, included=True)
|
|
self.tax_10_incl = self._create_tax("IVA 10% incl", 10.0, included=True)
|
|
# 100 net + 21% already inside the price.
|
|
self.product = self._create_product("Tax Incl Product", 121.0, self.tax_21_incl)
|
|
|
|
def _fiscal_position(self):
|
|
position = self.env["account.fiscal.position"].create(
|
|
{"name": "Remap 21->10", "company_id": self.company.id}
|
|
)
|
|
self.env["account.fiscal.position.tax"].create(
|
|
{
|
|
"position_id": position.id,
|
|
"tax_src_id": self.tax_21_incl.id,
|
|
"tax_dest_id": self.tax_10_incl.id,
|
|
}
|
|
)
|
|
return position
|
|
|
|
def test_price_is_rebased_onto_the_mapped_tax(self):
|
|
"""121 gross at 21% becomes 110 gross at 10%, not 121.
|
|
|
|
Without `_get_tax_included_unit_price_from_price` the 121 was handed
|
|
straight to the mapped tax, which read it as already being the 10%
|
|
gross price and displayed 121.
|
|
"""
|
|
self.website.fiscal_position_id = self._fiscal_position()
|
|
|
|
pricing = self.controller._get_pricing_info(
|
|
self.product, self.pricelist, quantity=1.0
|
|
)
|
|
|
|
self.assertAlmostEqual(pricing["price"], 110.0, places=2)
|
|
|
|
def test_price_is_untouched_without_a_fiscal_position(self):
|
|
"""The rebasing is a no-op in the ordinary case."""
|
|
self.website.fiscal_position_id = False
|
|
|
|
pricing = self.controller._get_pricing_info(
|
|
self.product, self.pricelist, quantity=1.0
|
|
)
|
|
|
|
self.assertAlmostEqual(pricing["price"], 121.0, places=2)
|
|
|
|
|
|
@tagged("post_install", "-at_install")
|
|
class TestPricingBatching(PricingDelegationCommon, TransactionCase):
|
|
"""A product listing resolves the pricelist once for the whole page."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.pricelist = self._create_pricelist("Batch PL", percent=10.0)
|
|
self.tax = self._create_tax("IVA 21%", 21.0)
|
|
self.products = self.env["product.product"]
|
|
for index in range(5):
|
|
self.products |= self._create_product(
|
|
f"Batch Product {index}", 100.0 + index, self.tax
|
|
)
|
|
|
|
def test_pricelist_is_resolved_once_for_the_page(self):
|
|
"""One `_compute_price_rule` call, not one per product."""
|
|
calls = []
|
|
original = type(self.pricelist)._compute_price_rule
|
|
|
|
def counting_compute_price_rule(pricelist_self, products, *args, **kwargs):
|
|
calls.append(len(products))
|
|
return original(pricelist_self, products, *args, **kwargs)
|
|
|
|
self.patch(
|
|
type(self.pricelist), "_compute_price_rule", counting_compute_price_rule
|
|
)
|
|
|
|
self.controller._compute_price_info(self.products, self.pricelist)
|
|
|
|
self.assertEqual(
|
|
calls,
|
|
[len(self.products)],
|
|
msg=f"Expected a single batched call, got {calls}",
|
|
)
|
|
|
|
def test_batched_prices_match_the_single_product_helper(self):
|
|
"""Batching must not change any number it produces."""
|
|
batched = self.controller._compute_price_info(self.products, self.pricelist)
|
|
|
|
for product in self.products:
|
|
single = self.controller._get_pricing_info(
|
|
product, self.pricelist, quantity=1.0
|
|
)
|
|
self.assertAlmostEqual(
|
|
batched[product.id]["price"], single["price"], places=2
|
|
)
|
|
self.assertAlmostEqual(
|
|
batched[product.id]["price_unit"], single["price_unit"], places=2
|
|
)
|
|
|
|
def test_prices_the_given_variant(self):
|
|
"""A variant is priced as itself, not as its template's first one."""
|
|
attribute = self.env["product.attribute"].create(
|
|
{
|
|
"name": "Size Deleg",
|
|
"value_ids": [
|
|
(0, 0, {"name": "Small"}),
|
|
(0, 0, {"name": "Large"}),
|
|
],
|
|
}
|
|
)
|
|
template = self.env["product.template"].create(
|
|
{
|
|
"name": "Multi Variant Product",
|
|
"type": "consu",
|
|
"list_price": 100.0,
|
|
"is_published": True,
|
|
"sale_ok": True,
|
|
"company_id": self.company.id,
|
|
"attribute_line_ids": [
|
|
(
|
|
0,
|
|
0,
|
|
{
|
|
"attribute_id": attribute.id,
|
|
"value_ids": [(6, 0, attribute.value_ids.ids)],
|
|
},
|
|
)
|
|
],
|
|
}
|
|
)
|
|
small, large = template.product_variant_ids[0], template.product_variant_ids[1]
|
|
# Make the two variants cost visibly different amounts.
|
|
large.write({"list_price": 100.0})
|
|
self.env["product.pricelist.item"].create(
|
|
{
|
|
"pricelist_id": self.pricelist.id,
|
|
"applied_on": "0_product_variant",
|
|
"product_id": large.id,
|
|
"compute_price": "fixed",
|
|
"fixed_price": 500.0,
|
|
}
|
|
)
|
|
|
|
priced = self.controller._compute_price_info(large, self.pricelist)
|
|
|
|
self.assertAlmostEqual(priced[large.id]["price_unit"], 500.0, places=2)
|
|
self.assertNotEqual(large.id, small.id)
|