From 4523a11bed332d7cb1f1846d2ff6b54f83afe430 Mon Sep 17 00:00:00 2001 From: Laurent Smet Date: Tue, 14 Nov 2017 14:16:49 +0000 Subject: [PATCH] [FIX] account, point_of_sale: Fix complex taxes computation The previous _compute_all wasn't able to get the right results in case of complex taxes computation. See tests for examples. task: 39945 --- addons/account/models/account.py | 210 +++++++++---- addons/account/tests/test_tax.py | 305 ++++++++++++++++--- addons/account_tax_python/tests/__init__.py | 3 + addons/account_tax_python/tests/test_tax.py | 61 ++++ addons/point_of_sale/static/src/js/models.js | 216 +++++++++---- 5 files changed, 630 insertions(+), 165 deletions(-) create mode 100644 addons/account_tax_python/tests/__init__.py create mode 100644 addons/account_tax_python/tests/test_tax.py diff --git a/addons/account/models/account.py b/addons/account/models/account.py index ba2615f946b..8bc67fcf1e8 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -901,7 +901,17 @@ class AccountTax(models.Model): type_tax_use = fields.Selection([('sale', 'Sales'), ('purchase', 'Purchases'), ('none', 'None'), ('adjustment', 'Adjustment')], string='Tax Scope', required=True, default="sale", help="Determines where the tax is selectable. Note : 'None' means a tax can't be used by itself, however it can still be used in a group. 'adjustment' is used to perform tax adjustment.") amount_type = fields.Selection(default='percent', string="Tax Computation", required=True, oldname='type', - selection=[('group', 'Group of Taxes'), ('fixed', 'Fixed'), ('percent', 'Percentage of Price'), ('division', 'Percentage of Price Tax Included')]) + selection=[('group', 'Group of Taxes'), ('fixed', 'Fixed'), ('percent', 'Percentage of Price'), ('division', 'Percentage of Price Tax Included')], + help=""" + - Group of Taxes: The tax is a set of sub taxes. + - Fixed: The tax amount stays the same whatever the price. + - Percentage of Price: The tax amount is a % of the price: + e.g 100 * 10% = 110 (not price included) + e.g 110 / (1 + 10%) = 100 (price included) + - Percentage of Price Tax Included: The tax amount is a division of the price: + e.g 180 / (1 - 10%) = 200 (not price included) + e.g 200 * (1 - 10%) = 180 (price included) + """) active = fields.Boolean(default=True, help="Set active to false to hide the tax without removing it.") company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.user.company_id) children_tax_ids = fields.Many2many('account.tax', 'account_tax_filiation_rel', 'parent_tax', 'child_tax', string='Children Taxes') @@ -1027,6 +1037,7 @@ class AccountTax(models.Model): price_unit * quantity eventually affected by previous taxes (if tax is include_base_amount XOR price_include) """ self.ensure_one() + if self.amount_type == 'fixed': # Use copysign to take into account the sign of the base amount which includes the sign # of the quantity and the sign of the price_unit @@ -1041,14 +1052,20 @@ class AccountTax(models.Model): else: return quantity * self.amount - price_include = self.price_include or self._context.get('force_price_include') + price_include = self._context['force_price_include'] if 'force_price_include' in self._context else self.price_include - if (self.amount_type == 'percent' and not price_include) or (self.amount_type == 'division' and price_include): + # base * (1 + tax_amount) = new_base + if self.amount_type == 'percent' and not price_include: return base_amount * self.amount / 100 + # <=> new_base = base / (1 + tax_amount) if self.amount_type == 'percent' and price_include: return base_amount - (base_amount / (1 + self.amount / 100)) + # base / (1 - tax_amount) = new_base if self.amount_type == 'division' and not price_include: return base_amount / (1 - self.amount / 100) - base_amount + # <=> new_base * (1 - tax_amount) = base + if self.amount_type == 'division' and price_include: + return base_amount - (base_amount * (self.amount / 100)) @api.multi def json_friendly_compute_all(self, price_unit, currency_id=None, quantity=1.0, product_id=None, partner_id=None): @@ -1080,15 +1097,44 @@ class AccountTax(models.Model): 'account_id': int, 'refund_account_id': int, 'analytic': boolean, - }] + }], } """ - if len(self) == 0: - company_id = self.env.user.company_id + if not self: + company = self.env.user.company_id else: - company_id = self[0].company_id + company = self[0].company_id + + # 1) Flatten the taxes. + def collect_taxes(self): + # Collect all the taxes recursively and ordered by sequence. + # Eg. considering letters as taxes and alphabetic order as sequence : + # [G, B([A, D, F]), E, C] will be computed as [A, D, F, C, E, G] + all_taxes = [] + for tax in self.sorted(key=lambda r: r.sequence): + if tax.amount_type == 'group': + all_taxes += collect_taxes(tax.children_tax_ids) + else: + all_taxes.append(tax) + return all_taxes + + taxes = collect_taxes(self) + + # 2) Avoid mixing taxes having price_include=False && include_base_amount=True + # with taxes having price_include=True. This use case is not supported as the + # computation of the total_excluded would be impossible. + base_excluded_flag = False # price_include=False && include_base_amount=True + included_flag = False # price_include=True + for tax in taxes: + if tax.price_include: + included_flag = True + elif tax.include_base_amount: + base_excluded_flag = True + if base_excluded_flag and included_flag: + raise UserError(_('Unable to mix any taxes being price included with taxes affecting the base amount but not included in price.')) + + # 3) Deal with the rounding methods if not currency: - currency = company_id.currency_id - taxes = [] + currency = company.currency_id # By default, for each tax, tax amount will first be computed # and rounded at the 'Account' decimal precision for each # PO/SO/invoice line and then these rounded amounts will be @@ -1104,7 +1150,7 @@ class AccountTax(models.Model): # amounts. For example, in SO/PO line, we don't want to round the price unit at the # precision of the currency. # The context key 'round' allows to force the standard behavior. - round_tax = False if company_id.tax_calculation_rounding_method == 'round_globally' else True + round_tax = False if company.tax_calculation_rounding_method == 'round_globally' else True round_total = True if 'round' in self.env.context: round_tax = bool(self.env.context['round']) @@ -1113,59 +1159,102 @@ class AccountTax(models.Model): if not round_tax: prec += 5 - base_values = self.env.context.get('base_values') - if not base_values: - total_excluded = total_included = total_void = base = round(price_unit * quantity, prec) - else: - total_excluded, total_included, total_void, base = base_values + # 4) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation. + # tax | base | amount | + # /\ ---------------------------- + # || tax_1 | XXXX | | <- we are looking for that, it's the total_excluded + # || tax_2 | .. | | + # || tax_3 | .. | | + # || ... | .. | .. | + # ---------------------------- + def recompute_base(base_amount, fixed_amount, percent_amount, division_amount): + # Recompute the new base amount based on included fixed/percent amounts and the current base amount. + # Example: + # tax | amount | type | price_include | + # ----------------------------------------------- + # tax_1 | 10% | percent | t + # tax_2 | 15 | fix | t + # tax_3 | 20% | percent | t + # tax_4 | 10% | division | t + # ----------------------------------------------- - # Sorting key is mandatory in this case. When no key is provided, sorted() will perform a - # search. However, the search method is overridden in account.tax in order to add a domain - # depending on the context. This domain might filter out some taxes from self, e.g. in the - # case of group taxes. - for tax in self.sorted(key=lambda r: r.sequence): - # Allow forcing price_include/include_base_amount through the context for the reconciliation widget. - # See task 24014. - price_include = self._context.get('force_price_include', tax.price_include) + # if base_amount = 145, the new base is computed as: + # (145 - 15) / (1.0 + 30%) * 90% = 130 / 1.3 * 90% = 90 + return (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0) * (100 - division_amount) / 100 - if tax.amount_type == 'group': - children = tax.children_tax_ids.with_context(base_values=(total_excluded, total_included, total_void, base)) - ret = children.compute_all(price_unit, currency, quantity, product, partner) - total_excluded = ret['total_excluded'] - base = ret['base'] if tax.include_base_amount else base - total_included = ret['total_included'] - total_void = ret['total_void'] - tax_amount = total_included - total_excluded - taxes += ret['taxes'] - continue + base = round(price_unit * quantity, prec) - tax_amount = tax._compute_amount(base, price_unit, quantity, product, partner) + # For the computation of move lines, we could have a negative base value. + # In this case, compute all with positive values and negate them at the end. + sign = 1 + if base < 0: + base = -base + sign = -1 + + # Store the totals to reach when using price_include taxes (only the last price included in row) + total_included_checkpoints = {} + i = len(taxes) - 1 + store_included_tax_total = True + # Keep track of the accumulated included fixed/percent amount. + incl_fixed_amount = incl_percent_amount = incl_division_amount = 0 + # Store the tax amounts we compute while searching for the total_excluded + cached_tax_amounts = {} + for tax in reversed(taxes): + if tax.include_base_amount: + base = recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount) + incl_fixed_amount = incl_percent_amount = incl_division_amount = 0 + store_included_tax_total = True + if tax.price_include: + if tax.amount_type == 'percent': + incl_percent_amount += tax.amount + elif tax.amount_type == 'division': + incl_division_amount += tax.amount + elif tax.amount_type == 'fixed': + incl_fixed_amount += quantity * tax.amount + else: + # tax.amount_type == other (python) + tax_amount = tax._compute_amount(base, price_unit, quantity, product, partner) + incl_fixed_amount += tax_amount + # Avoid unecessary re-computation + cached_tax_amounts[i] = tax_amount + if store_included_tax_total: + total_included_checkpoints[i] = base + store_included_tax_total = False + i -= 1 + + total_excluded = recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount) + + # 5) Iterate the taxes in the sequence order to compute missing tax amounts. + # Start the computation of accumulated amounts at the total_excluded value. + base = total_included = total_excluded + + taxes_vals = [] + i = 0 + cumulated_tax_included_amount = 0 + for tax in taxes: + #compute the tax_amount + if tax.price_include and total_included_checkpoints.get(i): + # We know the total to reach for that tax, so we make a substraction to avoid any rounding issues + tax_amount = total_included_checkpoints[i] - (base + cumulated_tax_included_amount) + cumulated_tax_included_amount = 0 + else: + tax_amount = tax.with_context(force_price_include=False)._compute_amount( + base, price_unit, quantity, product, partner) + + # Round the tax_amount if not round_tax: tax_amount = round(tax_amount, prec) else: tax_amount = currency.round(tax_amount) - if price_include: - total_excluded -= tax_amount - base -= tax_amount - else: - total_included += tax_amount - # The total_void amount is computed as the sum of total_excluded - # with all tax_amount, where tax has an account set - if not tax.account_id: - total_void += tax_amount + if tax.price_include and not total_included_checkpoints.get(i): + cumulated_tax_included_amount += tax_amount - # Keep base amount used for the current tax - tax_base = base - - if tax.include_base_amount: - base += tax_amount - - taxes.append({ + taxes_vals.append({ 'id': tax.id, - 'name': tax.with_context(**{'lang': partner.lang} if partner else {}).name, - 'amount': tax_amount, - 'base': tax_base, + 'name': partner and tax.with_context(lang=partner.lang).name or tax.name, + 'amount': sign * tax_amount, + 'base': sign * round(base, prec), 'sequence': tax.sequence, 'account_id': tax.account_id.id, 'refund_account_id': tax.refund_account_id.id, @@ -1174,12 +1263,17 @@ class AccountTax(models.Model): 'tax_exigibility': tax.tax_exigibility, }) + # Affect subsequent taxes + if tax.include_base_amount: + base += tax_amount + + total_included += tax_amount + i += 1 + return { - 'taxes': sorted(taxes, key=lambda k: k['sequence']), - 'total_excluded': currency.round(total_excluded) if round_total else total_excluded, - 'total_included': currency.round(total_included) if round_total else total_included, - 'total_void': currency.round(total_void) if round_total else total_void, - 'base': base, + 'taxes': taxes_vals, + 'total_excluded': sign * (currency.round(total_excluded) if round_total else total_excluded), + 'total_included': sign * (currency.round(total_included) if round_total else total_included), } @api.model diff --git a/addons/account/tests/test_tax.py b/addons/account/tests/test_tax.py index 7a763b3482e..d188896a72a 100644 --- a/addons/account/tests/test_tax.py +++ b/addons/account/tests/test_tax.py @@ -1,5 +1,5 @@ +# -*- coding: utf-8 -*- from odoo.addons.account.tests.account_test_users import AccountTestUsers -import time from odoo.tests import tagged @@ -27,6 +27,12 @@ class TestTax(AccountTestUsers): 'amount': 10, 'sequence': 3, }) + self.percent_tax_bis = self.tax_model.create({ + 'name': "Percent tax bis", + 'amount_type': 'percent', + 'amount': 10, + 'sequence': 4, + }) self.division_tax = self.tax_model.create({ 'name': "Division tax", 'amount_type': 'division', @@ -82,47 +88,118 @@ class TestTax(AccountTestUsers): self.bank_account = self.bank_journal.default_debit_account_id self.expense_account = self.env['account.account'].search([('user_type_id.type', '=', 'payable')], limit=1) #Should be done by onchange later + def _check_compute_all_results(self, total_included, total_excluded, taxes, res): + self.assertAlmostEqual(res['total_included'], total_included) + self.assertAlmostEqual(res['total_excluded'], total_excluded) + for i in range(0, len(taxes)): + self.assertAlmostEqual(res['taxes'][i]['base'], taxes[i][0]) + self.assertAlmostEqual(res['taxes'][i]['amount'], taxes[i][1]) + def test_tax_group_of_group_tax(self): self.fixed_tax.include_base_amount = True - self.group_tax.include_base_amount = True - self.group_of_group_tax.include_base_amount = True res = self.group_of_group_tax.compute_all(200.0) - self.assertEquals(res['total_excluded'], 200.0) - # After calculation of first group - # base = 210 - # total_included = 231 - # Base of the first grouped is passed - # Base after the second group (220) is dropped. - # Base of the group of groups is passed out, - # so we obtain base as after first group - self.assertEquals(res['base'], 210.0) - self.assertEquals(res['total_included'], 263.0) + self._check_compute_all_results( + 263, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 10.0), # | 1 | 10 | | t + (210.0, 21.0), # | 3 | 10% | | + (210.0, 10.0), # | 1 | 10 | | t + (220.0, 22.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) def test_tax_group(self): res = self.group_tax.compute_all(200.0) - self.assertEquals(res['total_excluded'], 200.0) - self.assertEquals(res['total_included'], 230.0) - self.assertEquals(len(res['taxes']), 2) - self.assertEquals(res['taxes'][0]['amount'], 10.0) - self.assertEquals(res['taxes'][1]['amount'], 20.0) + self._check_compute_all_results( + 230, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 10.0), # | 1 | 10 | | + (200.0, 20.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) def test_tax_percent_division(self): self.division_tax.price_include = True self.division_tax.include_base_amount = True + res_division = self.division_tax.compute_all(200.0) + self._check_compute_all_results( + 200, # 'total_included' + 180, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (180.0, 20.0), # | 4 | 10/ | t | t + # --------------------------------------------------- + ], + res_division + ) self.percent_tax.price_include = False self.percent_tax.include_base_amount = False - res_division = self.division_tax.compute_all(200.0) - res_percent = self.percent_tax.compute_all(200.0) - self.assertEquals(res_division['taxes'][0]['amount'], 20.0) - self.assertEquals(res_percent['taxes'][0]['amount'], 20.0) + res_percent = self.percent_tax.compute_all(100.0) + self._check_compute_all_results( + 110, # 'total_included' + 100, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (100.0, 10.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res_percent + ) self.division_tax.price_include = False self.division_tax.include_base_amount = False + res_division = self.division_tax.compute_all(180.0) + self._check_compute_all_results( + 200, # 'total_included' + 180, # 'total_excluded' + [ + # base, amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (180.0, 20.0), # | 4 | 10/ | | + # --------------------------------------------------- + ], + res_division + ) self.percent_tax.price_include = True self.percent_tax.include_base_amount = True - res_division = self.division_tax.compute_all(200.0) - res_percent = self.percent_tax.compute_all(200.0) - self.assertEquals(res_division['taxes'][0]['amount'], 22.22) - self.assertEquals(res_percent['taxes'][0]['amount'], 18.18) + res_percent = self.percent_tax.compute_all(110.0) + self._check_compute_all_results( + 110, # 'total_included' + 100, # 'total_excluded' + [ + # base, amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (100.0, 10.0), # | 3 | 10% | t | t + # --------------------------------------------------- + ], + res_percent + ) + self.percent_tax_bis.price_include = True + self.percent_tax_bis.include_base_amount = True + self.percent_tax_bis.amount = 21 + res_percent = self.percent_tax_bis.compute_all(7.0) + self._check_compute_all_results( + 7.0, # 'total_included' + 5.79, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (5.79, 1.21), # | 3 | 21% | t | t + # --------------------------------------------------- + ], + res_percent + ) def test_tax_sequence_normalized_set(self): self.division_tax.sequence = 1 @@ -130,28 +207,166 @@ class TestTax(AccountTestUsers): self.percent_tax.sequence = 3 taxes_set = (self.group_tax | self.division_tax) res = taxes_set.compute_all(200.0) - self.assertEquals(res['taxes'][0]['amount'], 22.22) - self.assertEquals(res['taxes'][1]['amount'], 10.0) - self.assertEquals(res['taxes'][2]['amount'], 20.0) + self._check_compute_all_results( + 252.22, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 22.22), # | 1 | 10/ | | + (200.0, 10.0), # | 2 | 10 | | + (200.0, 20.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) - def test_tax_include_base_amount(self): + def test_fixed_tax_include_base_amount(self): self.fixed_tax.include_base_amount = True res = self.group_tax.compute_all(200.0) - self.assertEquals(res['total_included'], 231.0) + self._check_compute_all_results( + 231, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 10.0), # | 1 | 10 | | t + (210.0, 21.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) - def test_tax_currency(self): - self.division_tax.amount = 15.0 - res = self.division_tax.compute_all(200.0, currency=self.env.ref('base.VEF')) - self.assertAlmostEqual(res['total_included'], 235.2941) + self.fixed_tax.price_include = True + self.fixed_tax.include_base_amount = False + res = self.fixed_tax.compute_all(100.0, quantity=2.0) + self._check_compute_all_results( + 200, # 'total_included' + 180, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (180.0, 20.0), # | 1 | 20 | | t + # --------------------------------------------------- + ], + res + ) - def test_tax_with_no_account(self): - self.tax_with_no_account.amount = 10.0 - res = self.tax_with_no_account.compute_all(200.0) - self.assertEquals(res['total_included'], 210) - self.assertEquals(res['total_void'], 210) + def test_percent_tax_include_base_amount(self): + self.percent_tax.price_include = True + self.percent_tax.amount = 21.0 + res = self.percent_tax.compute_all(7.0) + self._check_compute_all_results( + 7.0, # 'total_included' + 5.79, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (5.79, 1.21), # | 3 | 21% | t | + # --------------------------------------------------- + ], + res + ) - def test_tax_with_account(self): - self.tax_with_account.amount = 10.0 - res = self.tax_with_account.compute_all(200.0) - self.assertEquals(res['total_included'], 210) - self.assertEquals(res['total_void'], 200) + self.percent_tax.price_include = True + self.percent_tax.amount = 20.0 + res = self.percent_tax.compute_all(399.99) + self._check_compute_all_results( + 399.99, # 'total_included' + 333.33, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (333.33, 66.66), # | 3 | 20% | t | + # --------------------------------------------------- + ], + res + ) + + def test_tax_decimals(self): + """Test the rounding of taxes up to 6 decimals (maximum decimals places allowed for currencies)""" + self.env.user.company_id.currency_id.rounding = 0.000001 + + self.percent_tax.price_include = True + self.percent_tax.amount = 21.0 + res = self.percent_tax.compute_all(7.0) + self._check_compute_all_results( + 7.0, # 'total_included' + 5.785124, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # -------------------------------------------------------- + (5.785124, 1.214876), # | 3 | 21% | t | + # -------------------------------------------------------- + ], + res + ) + + self.percent_tax.price_include = True + self.percent_tax.amount = 20.0 + res = self.percent_tax.compute_all(399.999999) + self._check_compute_all_results( + 399.999999, # 'total_included' + 333.333333, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # ----------------------------------------------------------- + (333.333333, 66.666666), # | 3 | 20% | t | + # ----------------------------------------------------------- + ], + res + ) + + def test_advanced_taxes_computation_0(self): + '''Test more advanced taxes computation (see issue 34471).''' + tax_1 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_1', + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + 'include_base_amount': True, + 'sequence': 1, + }) + tax_2 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_2', + 'amount_type': 'percent', + 'amount': 10, + 'sequence': 2, + }) + tax_3 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_3', + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + 'sequence': 3, + }) + tax_4 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_4', + 'amount_type': 'percent', + 'amount': 10, + 'sequence': 4, + }) + tax_5 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_5', + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + 'sequence': 5, + }) + taxes = tax_1 + tax_2 + tax_3 + tax_4 + tax_5 + res = taxes.compute_all(132.0) + self._check_compute_all_results( + 154, # 'total_included' + 100, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (100.0, 10.0), # | 1 | 10% | t | t + (110.0, 11.0), # | 2 | 10% | | + (110.0, 11.0), # | 3 | 10% | t | + (110.0, 11.0), # | 4 | 10% | | + (110.0, 11.0), # | 5 | 10% | t | + # --------------------------------------------------- + ], + res + ) diff --git a/addons/account_tax_python/tests/__init__.py b/addons/account_tax_python/tests/__init__.py new file mode 100644 index 00000000000..b9eada7706e --- /dev/null +++ b/addons/account_tax_python/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import test_tax diff --git a/addons/account_tax_python/tests/test_tax.py b/addons/account_tax_python/tests/test_tax.py new file mode 100644 index 00000000000..2ad4529cd4b --- /dev/null +++ b/addons/account_tax_python/tests/test_tax.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.test_tax import TestTax +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestTaxPython(TestTax): + + def setUp(self): + super(TestTaxPython, self).setUp() + + self.python_tax = self.tax_model.create({ + 'name': 'Python TAx', + 'amount_type': 'code', + 'amount': 0.0, + 'python_compute': 'result = ((price_unit * quantity) - ((price_unit * quantity) / 1.12)) * 0.5', + 'sequence': 1, + }) + + def test_tax_python_basic(self): + res = self.python_tax.compute_all(130.0) + self._check_compute_all_results( + 136.96, # 'total_included' + 130.0, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # -------------------------------------------------- + (130.0, 6.96), # | 1 | 6% | t | + # -------------------------------------------------- + ], + res + ) + + def test_tax_python_price_include(self): + self.python_tax.price_include = True + res = self.python_tax.compute_all(130.0) + self._check_compute_all_results( + 130, # 'total_included' + 123.04, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (123.04, 6.96), # | 1 | 6% | t | + # --------------------------------------------------- + ], + res + ) + + res = (self.python_tax + self.python_tax).compute_all(130.0) + self._check_compute_all_results( + 130, # 'total_included' + 116.07, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (116.07, 6.96), # | 1 | 6% | t | + (116.07, 6.97), # | 1 | 6% | t | + # --------------------------------------------------- + ], + res + ) diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 68ba406c2f0..56f56dffb7f 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -27,7 +27,7 @@ var exports = {}; // it must contains a representation of the server's PoS backend. // (taxes, product list, configuration options, etc.) this representation // is fetched and stored by the PosModel at the initialisation. -// this is done asynchronously, a ready promise alows the GUI to wait interactively +// this is done asynchronously, a ready deferred alows the GUI to wait interactively // for the loading to be completed // There is a single instance of the PosModel for each Front-End instance, it is usually called // 'pos' and is available to all widgets extending PosWidget. @@ -1714,88 +1714,179 @@ exports.Orderline = Backbone.Model.extend({ return taxes; }, - _compute_all: function(tax, base_amount, quantity) { + /** + * Mirror JS method of: + * _compute_amount in addons/account/models/account.py + */ + _compute_all: function(tax, base_amount, quantity, price_exclude) { + if(price_exclude === undefined) + var price_include = tax.price_include; + else + var price_include = !price_exclude; if (tax.amount_type === 'fixed') { var sign_base_amount = base_amount >= 0 ? 1 : -1; return (Math.abs(tax.amount) * sign_base_amount) * quantity; } - if ((tax.amount_type === 'percent' && !tax.price_include) || (tax.amount_type === 'division' && tax.price_include)){ + if (tax.amount_type === 'percent' && !price_include){ return base_amount * tax.amount / 100; } - if (tax.amount_type === 'percent' && tax.price_include){ + if (tax.amount_type === 'percent' && price_include){ return base_amount - (base_amount / (1 + tax.amount / 100)); } - if (tax.amount_type === 'division' && !tax.price_include) { + if (tax.amount_type === 'division' && !price_include) { return base_amount / (1 - tax.amount / 100) - base_amount; } + if (tax.amount_type === 'division' && price_include) { + return base_amount - (base_amount * (tax.amount / 100)); + } return false; }, - compute_all: function(taxes, price_unit, quantity, currency_rounding, no_map_tax) { + /** + * Mirror JS method of: + * compute_all in addons/account/models/account.py + * + * Read comments in the python side method for more details about each sub-methods. + */ + compute_all: function(taxes, price_unit, quantity, currency_rounding) { var self = this; - var list_taxes = []; - var currency_rounding_bak = currency_rounding; - if (this.pos.company.tax_calculation_rounding_method == "round_globally"){ - currency_rounding = currency_rounding * 0.00001; - } - var total_excluded = round_pr(price_unit * quantity, currency_rounding); - var total_included = total_excluded; - var total_void = total_excluded; - var base = total_excluded; - var taxes_mapped = []; - if (!no_map_tax){ - _(taxes).each(function(tax){ - _(self._map_tax_fiscal_position(tax)).each(function(tax){ - taxes_mapped.push(tax); - }); + // 1) Flatten the taxes. + + var _collect_taxes = function(taxes, all_taxes){ + taxes.sort(function (tax1, tax2) { + return tax1.sequence - tax2.sequence; }); - } else { - taxes_mapped = taxes; + _(taxes).each(function(tax){ + if(tax.amount_type === 'group') + all_taxes = _collect_taxes(tax.children_tax_ids, all_taxes); + else + all_taxes.push(tax); + }); + return all_taxes; + } + var collect_taxes = function(taxes){ + return _collect_taxes(taxes, []); } - _(taxes_mapped).each(function(tax) { - if (tax.amount_type === 'group'){ - var ret = self.compute_all(tax.children_tax_ids, price_unit, quantity, currency_rounding); - total_excluded = ret.total_excluded; - base = ret.total_excluded; - total_included = ret.total_included; - total_void = ret.total_void; - list_taxes = list_taxes.concat(ret.taxes); - } - else { - var tax_amount = self._compute_all(tax, base, quantity); - tax_amount = round_pr(tax_amount, currency_rounding); - if (tax_amount){ - if (tax.price_include) { - total_excluded -= tax_amount; - base -= tax_amount; - } - else { - total_included += tax_amount; - } - if (tax.include_base_amount) { - base += tax_amount; - } - if (!tax.account_id) { - total_void += tax_amount; - } - var data = { - id: tax.id, - amount: tax_amount, - name: tax.name, - }; - list_taxes.push(data); + taxes = collect_taxes(taxes); + + // 2) Avoid dealing with taxes mixing price_include=False && include_base_amount=True + // with price_include=True + + var base_excluded_flag = false; // price_include=False && include_base_amount=True + var included_flag = false; // price_include=True + _(taxes).each(function(tax){ + if(tax.price_include) + included_flag = true; + else if(tax.include_base_amount) + base_excluded_flag = true; + if(base_excluded_flag && included_flag) + throw new Error('Unable to mix any taxes being price included with taxes affecting the base amount but not included in price.'); + }); + + // 3) Deal with the rounding methods + + var round_tax = this.pos.company.tax_calculation_rounding_method != 'round_globally'; + + if(!round_tax) + currency_rounding = currency_rounding * 0.00001; + + // 4) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation. + var recompute_base = function(base_amount, fixed_amount, percent_amount, division_amount){ + return (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0) * (100 - division_amount) / 100; + } + + var base = round_pr(price_unit * quantity, currency_rounding); + + var sign = 1; + if(base < 0){ + base = -base; + sign = -1; + } + + var total_included_checkpoints = {}; + var i = taxes.length - 1; + var store_included_tax_total = true; + + var incl_fixed_amount = 0.0; + var incl_percent_amount = 0.0; + var incl_division_amount = 0.0; + + var cached_tax_amounts = {}; + + _(taxes.reverse()).each(function(tax){ + if(tax.include_base_amount){ + base = recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount); + incl_fixed_amount = 0.0; + incl_percent_amount = 0.0; + incl_division_amount = 0.0; + store_included_tax_total = true; + } + if(tax.price_include){ + if(tax.amount_type === 'percent') + incl_percent_amount += tax.amount; + else if(tax.amount_type === 'division') + incl_division_amount += tax.amount; + else if(tax.amount_type === 'fixed') + incl_fixed_amount += quantity * tax.amount + else{ + tax_amount = self._compute_all(tax, base, quantity); + incl_fixed_amount += tax_amount; + cached_tax_amounts[i] = tax_amount; + } + if(store_included_tax_total){ + total_included_checkpoints[i] = base; + store_included_tax_total = false; } } + i -= 1; }); + + var total_excluded = recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount); + var total_included = total_excluded; + + // 5) Iterate the taxes in the sequence order to fill missing base/amount values. + + base = total_excluded; + + var taxes_vals = []; + i = 0; + var cumulated_tax_included_amount = 0; + _(taxes.reverse()).each(function(tax){ + if(tax.price_include && total_included_checkpoints[i] !== undefined){ + var tax_amount = total_included_checkpoints[i] - (base + cumulated_tax_included_amount); + cumulated_tax_included_amount = 0; + }else + var tax_amount = self._compute_all(tax, base, quantity, true); + + tax_amount = round_pr(tax_amount, currency_rounding); + + if(tax.price_include && total_included_checkpoints[i] === undefined) + cumulated_tax_included_amount += tax_amount; + + taxes_vals.push({ + 'id': tax.id, + 'name': tax.name, + 'amount': sign * tax_amount, + 'base': sign * round_pr(base, currency_rounding), + }); + + if(tax.include_base_amount) + base += tax_amount; + + total_included += tax_amount; + i += 1; + }); + return { - taxes: list_taxes, - total_excluded: round_pr(total_excluded, currency_rounding_bak), - total_included: round_pr(total_included, currency_rounding_bak), - total_void: round_pr(total_void, currency_rounding_bak) - }; + 'taxes': taxes_vals, + 'total_excluded': sign * round_pr(total_excluded, currency_rounding), + 'total_included': sign * round_pr(total_included, currency_rounding), + } }, get_all_prices: function(){ + var self = this; + var price_unit = this.get_unit_price() * (1.0 - (this.get_discount() / 100.0)); var taxtotal = 0; @@ -1806,9 +1897,10 @@ exports.Orderline = Backbone.Model.extend({ var product_taxes = []; _(taxes_ids).each(function(el){ - product_taxes.push(_.detect(taxes, function(t){ + var tax = _.detect(taxes, function(t){ return t.id === el; - })); + }); + product_taxes.push.apply(product_taxes, self._map_tax_fiscal_position(tax)); }); var all_taxes = this.compute_all(product_taxes, price_unit, this.get_quantity(), this.pos.currency.rounding);