# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo.tests import TransactionCase from hypothesis import given, settings, strategies as st from decimal import Decimal class TestPriceTotalProperty(TransactionCase): """ Property-based tests for price_total calculation with taxes. **Feature: vendor-bill-editable-totals, Property 4: Price total to unit price calculation with taxes** """ def setUp(self): super().setUp() # Create a vendor partner self.vendor = self.env['res.partner'].create({ 'name': 'Test Vendor', 'supplier_rank': 1, }) # Create a product self.product = self.env['res.product'].create({ 'name': 'Test Product', 'type': 'service', 'list_price': 100.0, }) # Create various tax configurations for testing # Tax 1: 10% tax excluded self.tax_10 = self.env['account.tax'].create({ 'name': 'Tax 10%', 'amount': 10.0, 'amount_type': 'percent', 'type_tax_use': 'purchase', 'price_include': False, }) # Tax 2: 5% tax excluded self.tax_5 = self.env['account.tax'].create({ 'name': 'Tax 5%', 'amount': 5.0, 'amount_type': 'percent', 'type_tax_use': 'purchase', 'price_include': False, }) # Tax 3: 20% tax excluded self.tax_20 = self.env['account.tax'].create({ 'name': 'Tax 20%', 'amount': 20.0, 'amount_type': 'percent', 'type_tax_use': 'purchase', 'price_include': False, }) # Tax 4: 15% tax included self.tax_15_included = self.env['account.tax'].create({ 'name': 'Tax 15% Included', 'amount': 15.0, 'amount_type': 'percent', 'type_tax_use': 'purchase', 'price_include': True, }) # Get decimal precision for Product Price self.precision = self.env['decimal.precision'].precision_get('Product Price') def _create_vendor_bill_line(self, quantity, price_unit, tax_ids): """Helper method to create a vendor bill line with specified parameters.""" vendor_bill = self.env['account.move'].create({ 'move_type': 'in_invoice', 'partner_id': self.vendor.id, 'invoice_date': '2024-01-01', }) line = self.env['account.move.line'].create({ 'move_id': vendor_bill.id, 'product_id': self.product.id, 'name': 'Test Line', 'quantity': quantity, 'price_unit': price_unit, 'tax_ids': [(6, 0, tax_ids)], }) return line @given( price_total=st.floats(min_value=0.01, max_value=1000000.0, allow_nan=False, allow_infinity=False), quantity=st.floats(min_value=1.0, max_value=10000.0, allow_nan=False, allow_infinity=False), tax_config=st.integers(min_value=0, max_value=5) ) @settings(max_examples=100, deadline=None) def test_property_price_total_with_taxes(self, price_total, quantity, tax_config): """ **Feature: vendor-bill-editable-totals, Property 4: Price total to unit price calculation with taxes** **Validates: Requirements 2.2, 2.3, 2.4** Property: For any invoice line with non-zero quantity, configured taxes, and a user-modified price_total value, the recalculated price_unit should result in a recomputed price_total that matches the user input within the configured decimal precision. """ # Map tax_config to different tax configurations # 0: No taxes # 1: Single tax (10%) # 2: Single tax (20%) # 3: Multiple taxes (10% + 5%) # 4: Multiple taxes (20% + 5%) # 5: Tax included (15%) tax_ids = [] if tax_config == 0: tax_ids = [] elif tax_config == 1: tax_ids = [self.tax_10.id] elif tax_config == 2: tax_ids = [self.tax_20.id] elif tax_config == 3: tax_ids = [self.tax_10.id, self.tax_5.id] elif tax_config == 4: tax_ids = [self.tax_20.id, self.tax_5.id] elif tax_config == 5: tax_ids = [self.tax_15_included.id] # Create an invoice line line = self._create_vendor_bill_line(quantity, 1.0, tax_ids) # Store the user input price_total user_input_total = price_total # Step 1: Set the price_total to trigger the onchange line.price_total = user_input_total line._onchange_price_total() # Step 2: Calculate what the recomputed price_total should be # using Odoo's tax computation with the new price_unit if not line.tax_ids: # No taxes: price_total should equal price_subtotal recomputed_total = line.price_unit * line.quantity else: # With taxes: use Odoo's tax computation API tax_results = line.tax_ids.compute_all( price_unit=line.price_unit, currency=line.currency_id, quantity=line.quantity, product=line.product_id, partner=line.move_id.partner_id ) recomputed_total = tax_results['total_included'] # Calculate the tolerance based on decimal precision # The tolerance accounts for: # 1. Rounding of price_unit to 'precision' decimal places # 2. Cumulative effect when multiplied by quantity # 3. Tax calculations that may introduce additional rounding unit_rounding_error = 0.5 * (10 ** (-self.precision)) base_tolerance = quantity * unit_rounding_error # For tax calculations, we need a more lenient tolerance because: # - Tax calculations involve division and multiplication # - Multiple taxes compound the rounding errors # - Tax-included calculations are more complex if tax_ids: # With taxes, allow for additional rounding in tax calculations # The tolerance is proportional to the price_total tolerance = max(base_tolerance * 2, user_input_total * 0.0001) # 0.01% or base tolerance else: # Without taxes, use the base tolerance tolerance = base_tolerance * 1.1 # 10% margin for floating point # Verify the property: recomputed price_total should match user input # within the configured decimal precision difference = abs(recomputed_total - user_input_total) self.assertLessEqual( difference, tolerance, msg=f"Round-trip accuracy failed for tax_config={tax_config}: " f"user input={user_input_total:.{self.precision}f}, " f"price_unit={line.price_unit:.{self.precision}f}, " f"quantity={quantity:.2f}, " f"recomputed={recomputed_total:.{self.precision}f}, " f"difference={difference:.{self.precision+2}f}, " f"tolerance={tolerance:.{self.precision+2}f}" )