From 455b5aaff82bdc286de05205a6bc39a87f5c36a2 Mon Sep 17 00:00:00 2001 From: Laurent Smet Date: Thu, 2 Nov 2017 14:02:04 +0100 Subject: [PATCH] [FIX] account, point_of_sale: fix rounding issues with taxes computation (#20573) Suppose a tax of 20% with price_included = True and apply this tax on 399.99: - base = 399.99 / 1.2 = 333.32500000000005 - tax_amount = base * 0.2 = 66.665 - round(base) + round(tax_amount) = 333.33 + 66.67 = 400.0 (!= 399.99: WRONG) -opw: 777925 --- addons/account/models/account.py | 56 +++++++++++++++++--- addons/account/tests/test_tax.py | 42 +++++++++++++-- addons/point_of_sale/static/src/js/models.js | 36 ++++++++++--- 3 files changed, 114 insertions(+), 20 deletions(-) diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 02ff42a09f3..2f9b3ee8bce 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -4,7 +4,7 @@ import time import math from openerp.osv import expression -from openerp.tools.float_utils import float_round as round +from openerp.tools.float_utils import float_round as round, float_is_zero as is_zero from openerp.tools import DEFAULT_SERVER_DATETIME_FORMAT from openerp.exceptions import UserError, ValidationError from openerp import api, fields, models, _ @@ -745,6 +745,23 @@ class AccountTax(models.Model): # || ... | .. | .. | # ---------------------------- + base = round(price_unit * quantity, prec) + + # Keep track of subsequent recomputed bases in order to avoid some rounding issues. + # For example, 399.99 computed with a tax 20% price_include leads to + # base = 399.99 / 1.2 = 333.32500000000005 + # tax_amount = base * 0.2 = 66.665 + # round(base) + round(tax_amount) = 333.33 + 66.67 = 400.0 (!= 399.99: WRONG) + # + # To fix such issues, base_gaps will contains amount between two bases. + # In our example, the gap between 333.32500000000005 and 399.99 is 66.66499999999996 + # + # Then, when processing the tax and because 66.665 - 66.66499999999996 is close to zero, + # the real gap is returned and so: + # tax_amount = 66.66499999999996 + # round(base) + round(tax_amount) = 333.33 + 66.66 = 399.99 (CORRECT) + base_gaps = [] + def recompute_base(base_amount, fixed_amount, percent_amount): # Recompute the new base amount based on included fixed/percent amount and the current base amount. # Example: @@ -758,9 +775,9 @@ class AccountTax(models.Model): # (145 - 15) / (1.0 + ((10 + 20) / 100.0)) = 130 / 1.3 = 100 if fixed_amount == 0.0 and percent_amount == 0.0: return base_amount - return (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0) - - base = round(price_unit * quantity, prec) + new_base = (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0) + base_gaps.append(base_amount - new_base) + return new_base # For the computation of move lines, we could have a negative base value. # In this case, compute all with positive values and negative them at the end. @@ -790,15 +807,38 @@ class AccountTax(models.Model): # || tax_1 | OK | XXXX | # || tax_2 | XXXX | XXXX | # || tax_3 | XXXX | XXXX | - # \/ ... | .. | .. | + # \/ ... | .. | .. | # ---------------------------- - taxes_vals = [] - for tax in taxes: + + def compute_amount(tax): # Compute the amount of the tax but don't deal with the price_include because it's already # took into account on the base amount except for 'division' tax: # (tax.amount_type == 'percent' && not tax.price_include) # == (tax.amount_type == 'division' && tax.price_include) - tax_amount = tax.with_context(force_price_include=False)._compute_amount(base, price_unit, quantity, product, partner) + # + # In case of price_included tax, subtract the amount to the corresponding + # gap between the current base and the next one. + amount = tax.with_context(force_price_include=False)._compute_amount( + base, price_unit, quantity, product, partner) + + if not tax.price_include or not base_gaps: + return amount + + # Compute the new gap after subtracting of the tax amount + new_gap = base_gaps[-1] - amount + + # If the newly computed gap is very close of zero, return the current gap to avoid + # rounding issues (see comments above base_gaps). + if is_zero(new_gap, prec): + return base_gaps.pop() + + # Update the current gap with the new one + base_gaps[-1] = new_gap + return amount + + taxes_vals = [] + for tax in taxes: + tax_amount = compute_amount(tax) if not round_tax: tax_amount = round(tax_amount, prec) else: diff --git a/addons/account/tests/test_tax.py b/addons/account/tests/test_tax.py index df12bb1dd3a..f688e5ba9be 100644 --- a/addons/account/tests/test_tax.py +++ b/addons/account/tests/test_tax.py @@ -188,7 +188,7 @@ class TestTax(AccountTestUsers): [ # base , amount | seq | amount | incl | incl_base # --------------------------------------------------- - (5.79, 1.21), # | 3 | 21% | t | t + (5.79, 1.21), # | 3 | 21% | t | t # --------------------------------------------------- ], res_percent @@ -232,6 +232,40 @@ class TestTax(AccountTestUsers): res ) + def test_tax_include_base_amount_2(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( + 5.79, # 'base' + 7.0, # 'total_included' + 5.79, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (5.79, 1.21), # | 3 | 10% | t | + # --------------------------------------------------- + ], + res + ) + + def test_tax_include_base_amount_3(self): + 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( + 333.33, # 'base' + 399.99, # 'total_included' + 333.33, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (333.33, 66.66), # | 3 | 10% | t | + # --------------------------------------------------- + ], + 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')) @@ -338,10 +372,10 @@ class TestTax(AccountTestUsers): # base , amount | seq | amount | incl | incl_base # --------------------------------------------------- (100.0, 10.0), # | 1 | 10% | t | t - (110.0, 11.0), # | 3 | 10% | | - (110.0, 11.0), # | 3 | 10% | t | - (110.0, 11.0), # | 3 | 10% | | + (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/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 5321e0ee13a..1df0dd7e3a0 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -87,7 +87,7 @@ exports.PosModel = Backbone.Model.extend({ // We fetch the backend data on the server asynchronously. this is done only when the pos user interface is launched, // Any change on this data made on the server is thus not reflected on the point of sale until it is relaunched. - // when all the data has loaded, we compute some stuff, and declare the Pos ready to be used. + // when all the data has loaded, we compute some stuff, and declare the Pos ready to be used. this.ready = this.load_server_data().then(function(){ return self.after_load_server_data(); }); @@ -1404,15 +1404,20 @@ exports.Orderline = Backbone.Model.extend({ currency_rounding = currency_rounding * 0.00001; } - var recompute_base = function(base_amount, fixed_amount, percent_amount){ - if(fixed_amount === 0.0 && percent_amount === 0.0) - return base_amount; - return (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0); - } - // 4) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation. var base = round_pr(price_unit * quantity, currency_rounding); + + var base_gaps = []; + + var recompute_base = function(base_amount, fixed_amount, percent_amount){ + if(fixed_amount === 0.0 && percent_amount === 0.0) + return base_amount; + var new_base = (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0); + base_gaps.push(base_amount - new_base); + return new_base; + } + var sign = 1; if(base < 0){ base = -base; @@ -1441,9 +1446,24 @@ exports.Orderline = Backbone.Model.extend({ // 5) Iterate the taxes in the sequence order to fill missing base/amount values. + var compute_amount = function(tax){ + var amount = self._compute_all(tax, base, quantity, false); + + if(!tax.price_include || base_gaps.length == 0) + return tax_amount; + + var new_gap = base_gaps[base_gaps.length - 1] - amount; + + if(round_pr(new_gap, currency_rounding) === 0.0) + return base_gaps.pop(); + + base_gaps[base_gaps.length - 1] = new_gap; + return amount; + }; + var taxes_vals = []; _(taxes.reverse()).each(function(tax){ - var tax_amount = self._compute_all(tax, base, quantity, false); + var tax_amount = compute_amount(tax); tax_amount = round_pr(tax_amount, currency_rounding); var tax_base = base;