diff options
| author | Olivier Colson <oco@odoo.com> | 2019-04-19 10:06:07 +0000 |
|---|---|---|
| committer | Olivier Colson <oco@odoo.com> | 2019-05-10 11:52:58 +0000 |
| commit | 3936d655c4bfd845b6ead724cecdc8e7c62816eb (patch) | |
| tree | 402e137e0fa09b06c1b237ab6a346670dea480c2 /addons/account | |
| parent | 574295df57d8da78ca6109f277a5a2332bf38101 (diff) | |
[IMP] account, l10n_*: v13 taxes
- Add repartition lines on taxes
- Link account tags directly to account.move.line; remove the tag_ids field from account.tag
- Add a new report engine dedicated to tax reports, directly generating account tags. It is called as an alternate mode of generic tax report, with a dedicated "Use tax grids" toggle.
>> The biggest change lies in the way the new tax report computes its values.
Everything is now aggregated directly using the tags set on the account move lines. Thanks to that,
modifying the configuration of a tax today will not impact the report for the previous periods anymore.
This is a big improvement, as it means the report will keep on reflecting the values that were submitted
to the state before, whatever the configuration change.
- Add an audit char field to account.move.line telling with tax grids are impacted by the line, with the corresponding amount
- Modify the behavior of cash basis taxes: the cash basis account is now used as the transition account, while the regular account given in tax declaration is used to store the final entry (it was the opposite before)
- Modify every l10n_* module in order to keep them consistent with these changes
closes odoo/odoo#32833
Signed-off-by: Quentin De Paoli (qdp) <qdp@openerp.com>
Diffstat (limited to 'addons/account')
| -rw-r--r-- | addons/account/__manifest__.py | 1 | ||||
| -rw-r--r-- | addons/account/models/account.py | 292 | ||||
| -rw-r--r-- | addons/account/models/account_invoice.py | 166 | ||||
| -rw-r--r-- | addons/account/models/account_move.py | 152 | ||||
| -rw-r--r-- | addons/account/models/chart_template.py | 143 | ||||
| -rw-r--r-- | addons/account/models/product.py | 2 | ||||
| -rw-r--r-- | addons/account/security/account_security.xml | 7 | ||||
| -rw-r--r-- | addons/account/security/ir.model.access.csv | 6 | ||||
| -rw-r--r-- | addons/account/tests/test_account_customer_invoice.py | 30 | ||||
| -rw-r--r-- | addons/account/tests/test_invoice_taxes.py | 106 | ||||
| -rw-r--r-- | addons/account/tests/test_reconciliation.py | 41 | ||||
| -rw-r--r-- | addons/account/tests/test_tax.py | 26 | ||||
| -rw-r--r-- | addons/account/tests/test_templates_consistency.py | 2 | ||||
| -rw-r--r-- | addons/account/views/account_invoice_view.xml | 6 | ||||
| -rw-r--r-- | addons/account/views/account_move_views.xml | 4 | ||||
| -rw-r--r-- | addons/account/views/account_tax_report_views.xml | 76 | ||||
| -rw-r--r-- | addons/account/views/account_view.xml | 66 | ||||
| -rw-r--r-- | addons/account/wizard/wizard_tax_adjustments.py | 39 |
18 files changed, 908 insertions, 257 deletions
diff --git a/addons/account/__manifest__.py b/addons/account/__manifest__.py index 5f46c40a8e8..9d042afbd3c 100644 --- a/addons/account/__manifest__.py +++ b/addons/account/__manifest__.py @@ -62,6 +62,7 @@ You could use this simplified accounting in case you work with an (external) acc 'data/account_incoterms_data.xml', 'views/digest_views.xml', 'wizard/account_invoice_send_views.xml', + 'views/account_tax_report_views.xml', ], 'demo': [ 'demo/account_demo.xml', diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 431cd15fbce..f77eee4d956 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -5,7 +5,7 @@ import math import re from odoo.osv import expression -from odoo.tools.float_utils import float_round as round +from odoo.tools.float_utils import float_round as round, float_compare from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT from odoo.exceptions import UserError, ValidationError from odoo import api, fields, models, _ @@ -45,6 +45,123 @@ class AccountAccountTag(models.Model): applicability = fields.Selection([('accounts', 'Accounts'), ('taxes', 'Taxes')], required=True, default='accounts') color = fields.Integer('Color Index') active = fields.Boolean(default=True, help="Set active to false to hide the Account Tag without removing it.") + tax_report_line_ids = fields.Many2many(string="Tax Report Lines", comodel_name='account.tax.report.line', relation='account_tax_report_line_tags_rel', help="The tax report lines using this tag") + tax_negate = fields.Boolean(string="Negate Tax Balance", help="Check this boox to negate the absolute value of the balance of the lines associated with this tag in tax report computation.") + country_id = fields.Many2one(string="Country", comodel_name='res.country', help="Country for which this tag is available, when applied on taxes.") + + +class AccountTaxReportLine(models.Model): + _name = "account.tax.report.line" + _description = 'Account Tax Report Line' + _order = 'sequence' + _parent_store = True + + name = fields.Char(string="Name", required=True, help="Complete name for this report line, to be used in report.") + tag_ids = fields.Many2many(string="Tags", comodel_name='account.account.tag', relation='account_tax_report_line_tags_rel', help="Tax tags populating this line") + country_id = fields.Many2one(string="Country", comodel_name='res.country', required=True, default=lambda x: x.env.user.company_id.country_id.id, help="Country for which this line is available.") + report_action_id = fields.Many2one(string="Report Action", comodel_name='ir.actions.act_window', help="The optional action to call when clicking on this line in accounting reports.") + children_line_ids = fields.One2many(string="Children Lines", comodel_name='account.tax.report.line', inverse_name='parent_id', help="Lines that should be rendered as children of this one") + parent_id = fields.Many2one(string="Parent Line", comodel_name='account.tax.report.line') + sequence = fields.Integer(string='Sequence', required=True, help="Sequence determining the order of the lines in the report (smaller ones come first). This order is applied locally per section (so, chilldren of the same line are always rendered one after the other).") + parent_path = fields.Char(index=True) + + #helper to create tags (positive and negative) on report line creation + tag_name = fields.Char(string="Tag Name", help="Short name for the tax grid corresponding to this report line. Leave empty if this report line should not correspond to any such grid.") + + #fields used in specific localization reports, where a report line isn't simply the given by the sum of account.move.line with selected tags + code = fields.Char(string="Code", help="Optional unique code to refer to this line in total formulas") + formula = fields.Char(string="Formula", help="Python expression used to compute the value of a total line. This field is mutually exclusive with tag_name, setting it turns the line to a total line. Tax report line codes can be used as variables in this expression to refer to the balance of the corresponding lines in the report. A formula cannot refer to another line using a formula.") + + @api.model + def create(self, vals): + tag_name = vals.get('tag_name', '') + if tag_name: + vals['tag_ids'] = self._get_tags_create_vals(tag_name, vals.get('country_id')) + return super(AccountTaxReportLine, self).create(vals) + + @api.model + def _get_tags_create_vals(self, tag_name, country_id): + minus_tag_vals = { + 'name': '-' + tag_name, + 'applicability': 'taxes', + 'tax_negate': True, + 'country_id': country_id, + } + plus_tag_vals = { + 'name': '+' + tag_name, + 'applicability': 'taxes', + 'tax_negate': False, + 'country_id': country_id, + } + return [(0, 0, minus_tag_vals), (0, 0, plus_tag_vals)] + + def write(self, vals): + rslt = super(AccountTaxReportLine, self).write(vals) + if 'tag_name' in vals: + # Constraint _validate_tags ensures that tag_ids always contains two + # tags if not empty: + and -. + # So, we can impact the whole tag_ids set if tag_name is modified. + if vals['tag_name']: + # If some lines in the set did not have tags yet, we create them + without_tags = self.filtered(lambda x: not x.tag_ids) + for record in without_tags: + record.write({'tag_ids': self._get_tags_create_vals(record.tag_name, record.country_id.id)}) + + # We update the name of every tag linked to other lines + to_update = self - without_tags + tags_to_update = to_update.mapped('tag_ids') + minus_child_tags = tags_to_update.filtered(lambda x: x.tax_negate) + minus_child_tags.write({'name': '-' + vals['tag_name']}) + + plus_child_tags = tags_to_update.filtered(lambda x: not x.tax_negate) + plus_child_tags.write({'name': '+' + vals['tag_name']}) + + else: + # tag_name was set empty, so we remove the tags + self._delete_tags_from_taxes(self.mapped('tag_ids.id')) + self.write({'tag_ids': [(2, tag.id, 0) for tag in self.mapped('tag_ids')]}) + + return rslt + + def unlink(self): + self._delete_tags_from_taxes(self.mapped('tag_ids.id')) + children = self.mapped('children_line_ids') + if children: + children.unlink() + return super(AccountTaxReportLine, self).unlink() + + def _delete_tags_from_taxes(self, tag_ids_to_delete): + """ Based on a list of tag ids, delete them all from database, removing them + first from the repartition lines they are linked to. + """ + repartition_lines = self.env['account.tax.repartition.line'].search([('tag_ids', 'in', tag_ids_to_delete)]) + repartition_lines.write({'tag_ids': [(3, tag_id, 0) for tag_id in tag_ids_to_delete]}) + + @api.constrains('formula', 'tag_name') + def _validate_formula(self): + if self.formula and self.tag_name: + raise ValidationError(_("Tag name and formula are mutually exclusive, they should not be set together on the same tax report line.")) + + @api.constrains('tag_name', 'tag_ids') + def _validate_tags(self): + neg_tags = self.tag_ids.filtered(lambda x: x.tax_negate) + pos_tags = self.tag_ids.filtered(lambda x: not x.tax_negate) + if self.tag_ids and (len(neg_tags) !=1 or len(pos_tags) != 1): + raise ValidationError(_("If tags are defined for a tax report line, only two are allowed on it: a positive and a negative one.")) + + @api.onchange('parent_id') + def _onchange_parent_id(self): + """ If the parent of a report line is changed, the sequence of this line + must be updated, in order for it to match the sequence numbering of its + parent. By default, we add it as the last line of this section. + """ + if self.country_id: + lines_domain = [('country_id', '=', self.country_id.id)] + if self.parent_id: + lines_domain.append(('parent_id', '=', self.parent_id.id)) + + last_line = self.env['account.tax.report.line'].search(lines_domain, order="sequence desc", limit=1) + self.sequence = last_line.sequence + 1 #---------------------------------------------------------- # Accounts @@ -918,17 +1035,12 @@ class AccountTax(models.Model): sequence = fields.Integer(required=True, default=1, help="The sequence field is used to define order in which the tax lines are applied.") amount = fields.Float(required=True, digits=(16, 4)) - account_id = fields.Many2one('account.account', domain=[('deprecated', '=', False)], string='Tax Account', ondelete='restrict', - help="Account that will be set on invoice tax lines for invoices. Leave empty to use the expense account.", oldname='account_collected_id') - refund_account_id = fields.Many2one('account.account', domain=[('deprecated', '=', False)], string='Tax Account on Credit Notes', ondelete='restrict', - help="Account that will be set on invoice tax lines for credit notes. Leave empty to use the expense account.", oldname='account_paid_id') description = fields.Char(string='Label on Invoices') price_include = fields.Boolean(string='Included in Price', default=False, help="Check this if the price you use on the product and invoices includes this tax.") include_base_amount = fields.Boolean(string='Affect Base of Subsequent Taxes', default=False, help="If set, taxes which are computed after this one will be computed based on the price tax included.") analytic = fields.Boolean(string="Include in Analytic Cost", help="If set, the amount computed by this tax will be assigned to the same analytic account as the invoice line (if any)") - tag_ids = fields.Many2many('account.account.tag', 'account_tax_account_tag', string='Tags', help="Optional tags you may want to assign for custom reporting") tax_group_id = fields.Many2one('account.tax.group', string="Tax Group", default=_default_tax_group, required=True) # Technical field to make the 'tax_exigibility' field invisible if the same named field is set to false in 'res.company' model hide_tax_exigibility = fields.Boolean(string='Hide Use Cash Basis Option', related='company_id.tax_exigibility', readonly=True) @@ -939,22 +1051,55 @@ class AccountTax(models.Model): oldname='use_cash_basis', help="Based on Invoice: the tax is due as soon as the invoice is validated.\n" "Based on Payment: the tax is due as soon as the payment of the invoice is received.") - cash_basis_account_id = fields.Many2one( - 'account.account', - string='Tax Received Account', - domain=[('deprecated', '=', False)], - oldname='cash_basis_account', - help='Account used as counterpart for the journal entry, for taxes eligible based on payments.') + cash_basis_transition_account_id = fields.Many2one(string="Cash Basis Transition Account", domain=[('deprecated', '=', False)], comodel_name='account.account', help="Account used to transition the tax amount for cash basis taxes. It will contain the tax amount as long as the original invoice has not been reconciled ; at reconciliation, this amount cancelled on this account and put on the regular tax account.") cash_basis_base_account_id = fields.Many2one( 'account.account', domain=[('deprecated', '=', False)], string='Base Tax Received Account', help='Account that will be set on lines created in cash basis journal entry and used to keep track of the tax base amount.') + invoice_repartition_line_ids = fields.One2many(string="Repartition for Invoices", comodel_name="account.tax.repartition.line", inverse_name="invoice_tax_id", copy=True, help="Repartition when the tax is used on an invoice") + refund_repartition_line_ids = fields.One2many(string="Repartition for Refund Invoices", comodel_name="account.tax.repartition.line", inverse_name="refund_tax_id", copy=True, help="Repartition when the tax is used on a refund") + country_id = fields.Many2one(string='Country', comodel_name='res.country', related='company_id.country_id', help="Technical field used to restrict the domain of account tags for tax repartition lines created for this tax.") _sql_constraints = [ ('name_company_uniq', 'unique(name, company_id, type_tax_use)', 'Tax names must be unique !'), ] + @api.model + def default_get(self, vals): + rslt = super(AccountTax, self).default_get(vals) + + if 'refund_repartition_line_ids' in vals: + rslt['refund_repartition_line_ids'] = [ + (0, 0, { 'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': rslt.get('company_id')}), + (0, 0, { 'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': rslt.get('company_id')}), + ] + + if 'invoice_repartition_line_ids' in vals: + rslt['invoice_repartition_line_ids'] = [ + (0, 0, { 'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': rslt.get('company_id')}), + (0, 0, { 'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': rslt.get('company_id')}), + ] + + return rslt + + def _check_repartition_lines(self, lines): + self.ensure_one() + + base_line = lines.filtered(lambda x: x.repartition_type == 'base') + if len(base_line) != 1: + raise ValidationError(_("Invoice and credit note repartition should each contain exactly one line for the base.")) + + @api.constrains('invoice_repartition_line_ids', 'refund_repartition_line_ids') + def _validate_repartition_lines(self): + for record in self: + record._check_repartition_lines(record.invoice_repartition_line_ids) + record._check_repartition_lines(record.refund_repartition_line_ids) + sum_inv_tax = sum(record.mapped('invoice_repartition_line_ids.factor_percent')) + sum_refund_tax = sum(record.mapped('refund_repartition_line_ids.factor_percent')) + if float_compare(sum_inv_tax, sum_refund_tax, precision_digits=4) != 0: + raise ValidationError(_("You should impact the same total percentage of the tax amount for invoices and refunds.")) + @api.one @api.constrains('children_tax_ids', 'type_tax_use') def _check_children_scope(self): @@ -1015,10 +1160,6 @@ class AccountTax(models.Model): if self.amount_type is not 'group': self.children_tax_ids = [(5,)] - @api.onchange('account_id') - def onchange_account_id(self): - self.refund_account_id = self.account_id - @api.onchange('price_include') def onchange_price_include(self): if self.price_include: @@ -1028,6 +1169,7 @@ class AccountTax(models.Model): """ Returns a string that will be used to group account.invoice.tax sharing the same properties""" self.ensure_one() return str(invoice_tax_val['tax_id']) + '-' + \ + str(invoice_tax_val.get('tax_repartition_line_id')) + '-' + \ str(invoice_tax_val['account_id']) + '-' + \ str(invoice_tax_val['account_analytic_id']) + '-' + \ str(invoice_tax_val.get('analytic_tag_ids', [])) + '-' + \ @@ -1069,7 +1211,7 @@ class AccountTax(models.Model): 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): + def json_friendly_compute_all(self, price_unit, currency_id=None, quantity=1.0, product_id=None, partner_id=None, is_refund=False): """ Just converts parameters in browse records and calls for compute_all, because js widgets can't serialize browse records """ if currency_id: currency_id = self.env['res.currency'].browse(currency_id) @@ -1077,10 +1219,23 @@ class AccountTax(models.Model): product_id = self.env['product.product'].browse(product_id) if partner_id: partner_id = self.env['res.partner'].browse(partner_id) - return self.compute_all(price_unit, currency=currency_id, quantity=quantity, product=product_id, partner=partner_id) + return self.compute_all(price_unit, currency=currency_id, quantity=quantity, product=product_id, partner=partner_id, is_refund=is_refund) + + def flatten_taxes_hierarchy(self): + # Flattens the taxes contained in this recordset, returning all the + # children at the bottom of the hierarchy, in a recordset, 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 = self.env['account.tax'] + for tax in self.sorted(key=lambda r: r.sequence): + if tax.amount_type == 'group': + all_taxes += tax.children_tax_ids.flatten_taxes_hierarchy() + else: + all_taxes += tax + return all_taxes @api.multi - def compute_all(self, price_unit, currency=None, quantity=1.0, product=None, partner=None): + def compute_all(self, price_unit, currency=None, quantity=1.0, product=None, partner=None, is_refund=False): """ Returns all information required to apply taxes (in self + their children in case of a tax group). We consider the sequence of the parent for group of taxes. Eg. considering letters as taxes and alphabetic order as sequence : @@ -1106,19 +1261,7 @@ class AccountTax(models.Model): 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) + taxes = self.flatten_taxes_hierarchy() # 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 @@ -1227,7 +1370,7 @@ class AccountTax(models.Model): # 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 + base = total_included = total_void = total_excluded taxes_vals = [] i = 0 @@ -1243,26 +1386,35 @@ class AccountTax(models.Model): 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) + tax_amount = round(tax_amount, prec) if tax.price_include and not total_included_checkpoints.get(i): cumulated_tax_included_amount += tax_amount - taxes_vals.append({ - 'id': tax.id, - '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, - 'analytic': tax.analytic, - 'price_include': tax.price_include, - 'tax_exigibility': tax.tax_exigibility, - }) + tax_repartition_lines = is_refund and tax.refund_repartition_line_ids or tax.invoice_repartition_line_ids + repartition_lines_to_treat = len(tax_repartition_lines) + total_amount = 0 + for repartition_line in tax_repartition_lines.filtered(lambda x: x.repartition_type == 'tax'): + # In case some rounding error occurs, we compensate for it on the last line + line_amount = round(sign * tax_amount * repartition_line.factor if repartition_lines_to_treat != 1 else tax_amount - total_amount, prec) + taxes_vals.append({ + 'id': tax.id, + 'name': partner and tax.with_context(lang=partner.lang).name or tax.name, + 'amount': line_amount, + 'base': round(sign * base, prec), + 'sequence': tax.sequence, + 'account_id': tax.cash_basis_transition_account_id.id if tax.tax_exigibility == 'on_payment' else repartition_line.account_id.id, + 'analytic': tax.analytic, + 'price_include': tax.price_include, + 'tax_exigibility': tax.tax_exigibility, + 'tax_repartition_line_id': repartition_line.id, + 'tag_ids': [(6, False, repartition_line.tag_ids.ids)], + }) + + total_amount += line_amount + if not repartition_line.account_id: + total_void += line_amount + repartition_lines_to_treat -= 1 # Affect subsequent taxes if tax.include_base_amount: @@ -1272,9 +1424,11 @@ class AccountTax(models.Model): i += 1 return { + 'base_tags': taxes.mapped(is_refund and 'refund_repartition_line_ids' or 'invoice_repartition_line_ids').filtered(lambda x: x.repartition_type == 'base').mapped('tag_ids').ids, '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), + 'total_void': sign * (currency.round(total_void) if round_total else total_void), } @api.model @@ -1293,3 +1447,43 @@ class AccountTax(models.Model): prod_taxes = prod_taxes.filtered(lambda tax: tax.company_id == company_id) line_taxes = line_taxes.filtered(lambda tax: tax.company_id == company_id) return self._fix_tax_included_price(price, prod_taxes, line_taxes) + + +class AccountTaxRepartitionLine(models.Model): + _name = "account.tax.repartition.line" + _description = "Tax Repartition Line" + _order = 'sequence, repartition_type, id' + + factor_percent = fields.Float(string="%", required=True, help="Factor to apply on the account move lines generated from this repartition line, in percents") + factor = fields.Float(string="Factor Ratio", compute="_compute_factor", help="Factor to apply on the account move lines generated from this repartition line") + repartition_type = fields.Selection(string="Based On", selection=[('base', 'Base'), ('tax', 'of tax')], required=True, default='tax', help="Base on which the factor will be applied.") + account_id = fields.Many2one(string="Account", comodel_name='account.account', help="Account on which to post the tax amount") + tag_ids = fields.Many2many(string="Tax Grid", comodel_name='account.account.tag', domain=[('applicability', '=', 'taxes')], copy=True) + invoice_tax_id = fields.Many2one(comodel_name='account.tax', help="The tax set to apply this repartition on invoices. Mutually exclusive with refund_tax_id") + refund_tax_id = fields.Many2one(comodel_name='account.tax', help="The tax set to apply this repartition on refund invoices. Mutually exclusive with invoice_tax_id") + tax_id = fields.Many2one(comodel_name='account.tax', compute='_compute_tax_id') + country_id = fields.Many2one(string="Country", comodel_name='res.country', related='company_id.country_id', help="Technical field used to restrict tags domain in form view.") + company_id = fields.Many2one(string="Company", comodel_name='res.company', required=True, default=lambda x: x.env.user.company_id, help="The company this repartition line belongs to.") + sequence = fields.Integer(string="Sequence", default=1, help="The order in which display and match repartition lines. For refunds to work properly, invoice repartition lines should be arranged in the same order as the credit note repartition lines they correspond to.") + + @api.constrains('invoice_tax_id', 'refund_tax_id') + def validate_tax_template_link(self): + for record in self: + if record.invoice_tax_id and record.refund_tax_id: + raise ValidationError(_("Tax repartition lines should apply to either invoices or refunds, not both at the same time. invoice_tax_id and refund_tax_id should not be set together.")) + + @api.depends('factor_percent') + def _compute_factor(self): + for record in self: + record.factor = record.factor_percent / 100.0 + + @api.depends('invoice_tax_id', 'refund_tax_id') + def _compute_tax_id(self): + for record in self: + record.tax_id = record.invoice_tax_id or record.refund_tax_id + + @api.onchange('repartition_type') + def _onchange_repartition_type(self): + if self.repartition_type == 'base': + self.account_id = None + diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index e11c31ae6a0..4dff98dd9ae 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -860,6 +860,7 @@ class AccountInvoice(models.Model): taxes_grouped = self.get_taxes_values() tax_lines = self.tax_line_ids.filtered('manual') for tax in taxes_grouped.values(): + # ATTENTION: due to this, fields in tax have to be in the view (possibly invisible), as they won't be saved otherwise (when hitting "save") tax_lines += tax_lines.new(tax) self.tax_line_ids = tax_lines return @@ -1093,9 +1094,10 @@ class AccountInvoice(models.Model): 'manual': False, 'sequence': tax['sequence'], 'account_analytic_id': tax['analytic'] and line.account_analytic_id.id or False, - 'account_id': self.type in ('out_invoice', 'in_invoice') and (tax['account_id'] or line.account_id.id) or (tax['refund_account_id'] or line.account_id.id), + 'account_id': tax['account_id'] or line.account_id.id, 'analytic_tag_ids': tax['analytic'] and line.analytic_tag_ids.ids or False, 'tax_ids': tax_ids and [(6, None, tax_ids)] or False, + 'tax_repartition_line_id': tax.get('tax_repartition_line_id'), # For base amount, we let this field empty } # If the taxes generate moves on the same financial account as the invoice line, @@ -1123,7 +1125,7 @@ class AccountInvoice(models.Model): continue price_unit = line.price_unit * (1 - (line.discount or 0.0) / 100.0) - taxes = line.invoice_line_tax_ids.compute_all(price_unit, self.currency_id, line.quantity, line.product_id, self.partner_id)['taxes'] + taxes = line.invoice_line_tax_ids.compute_all(price_unit, self.currency_id, line.quantity, line.product_id, self.partner_id, is_refund=self.type in ('in_refund', 'out_refund'))['taxes'] affecting_base_tax_ids = [] for tax_vals in taxes: @@ -1134,25 +1136,20 @@ class AccountInvoice(models.Model): tax = tax_map[tax_vals['id']] = self.env['account.tax'].browse(tax_vals['id']) val = self._prepare_tax_line_vals(line, tax_vals, affecting_base_tax_ids) - key = tax.get_grouping_key({ - 'tax_id': val['tax_id'], - 'account_id': val['account_id'], - 'account_analytic_id': val['account_analytic_id'], - 'analytic_tag_ids': val['analytic_tag_ids'], - 'tax_ids': affecting_base_tax_ids, - }) + key = tax.get_grouping_key(val) if key not in tax_grouped: tax_grouped[key] = val tax_grouped[key]['base'] = round_curr(val['base']) else: for field in default_tax_group_fields: - tax_grouped[key][field] += val.get(field) or 0 + tax_grouped[key][field] += round_curr(val.get(field)) or 0 if tax.amount_type == "group": affecting_base_tax_ids += tax.children_tax_ids.filtered(lambda t: is_tax_affecting_base_amount(t)).ids elif is_tax_affecting_base_amount(tax): affecting_base_tax_ids.append(tax.id) + return tax_grouped @api.multi @@ -1234,13 +1231,19 @@ class AccountInvoice(models.Model): continue if line.quantity==0: continue - tax_ids = [] - for tax in line.invoice_line_tax_ids: - tax_ids.append((4, tax.id, None)) - for child in tax.children_tax_ids: - tax_ids.append((4, child.id, None)) + + taxes_to_flatten = line.invoice_line_tax_ids + flattened_taxes = self.env['account.tax'] + while taxes_to_flatten: + tax = taxes_to_flatten[0] + taxes_to_flatten -= tax + flattened_taxes += tax + taxes_to_flatten += tax.children_tax_ids + analytic_tag_ids = [(4, analytic_tag.id, None) for analytic_tag in line.analytic_tag_ids] + tax_repartition_field_name = 'invoice_repartition_line_ids' if line.invoice_type in ('in_invoice', 'out_invoice') else 'refund_repartition_line_ids' + tag_ids = flattened_taxes.mapped(tax_repartition_field_name).filtered(lambda x: x.repartition_type == 'base').mapped('tag_ids.id') move_line_dict = { 'invl_id': line.id, 'type': 'src', @@ -1253,13 +1256,13 @@ class AccountInvoice(models.Model): 'uom_id': line.uom_id.id, 'account_analytic_id': line.account_analytic_id.id, 'analytic_tag_ids': analytic_tag_ids, - 'tax_ids': tax_ids, + 'tax_ids': [(6, 0, flattened_taxes.ids)], 'invoice_id': self.id, + 'tag_ids': [(6, 0, tag_ids)], } res.append(move_line_dict) return res - @api.multi def tax_line_move_line_get(self): self.ensure_one() @@ -1280,7 +1283,9 @@ class AccountInvoice(models.Model): 'account_analytic_id': tax_line.account_analytic_id.id, 'analytic_tag_ids': analytic_tag_ids, 'invoice_id': self.id, - 'tax_ids': [(6, 0, tax_line.tax_ids.ids)], + 'tax_ids': tax_line.tax_ids and [(6, 0, tax_line.tax_ids.ids)] or False, # We don't pass an empty recordset here, as it would reset the tax_exibility of the line, due to the condition in account.move.line's create + 'tax_repartition_line_id': tax_line.tax_repartition_line_id.id, + 'tag_ids': [(6, 0, tax_line.tax_repartition_line_id.tag_ids.ids)], }) return res @@ -1543,21 +1548,6 @@ class AccountInvoice(models.Model): result.append((0, 0, values)) return result - @api.model - def _refund_tax_lines_account_change(self, lines, taxes_to_change): - # Let's change the account on tax lines when - # @param {list} lines: a list of orm commands - # @param {dict} taxes_to_change - # key: tax ID, value: refund account - - if not taxes_to_change: - return lines - - for line in lines: - if isinstance(line[2], dict) and line[2]['tax_id'] in taxes_to_change: - line[2]['account_id'] = taxes_to_change[line[2]['tax_id']] - return lines - def _get_refund_common_fields(self): return ['partner_id', 'payment_term_id', 'account_id', 'currency_id', 'journal_id'] @@ -1580,6 +1570,87 @@ class AccountInvoice(models.Model): return self.date or self.date_invoice @api.model + def _create_refund_repartition_mapping(self, taxes): + """ Creates a mapping between tax and refund repartition lines of the + provided taxes, using the sequence of repartition lines to match them. + This function is used in order to fix account.invoice.tax objects generated + for refunds when tax amounts have been modified manually. + + For example, a tax with the following tax repartition... + - INVOICE: + 1) 30% + 2) 70% + - REFUND + 3) 40% + 4) 60% + + ... will group line 1) with line 3), and line 2) with lines 3) and 4). + + :return: A dictionnary, with invoice repartition line ids as keys, and lists + of corresponding refund repartition line ids as values. + """ + rslt = {} + for tax in taxes: + inv_index = 0 + ref_index = 0 + + tax_inv_lines = tax.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax') + tax_ref_lines = tax.refund_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax') + + amount_to_match = tax_inv_lines[inv_index].factor_percent + while inv_index < len(tax_inv_lines) or ref_index < len(tax_ref_lines): + inv_rep_line = inv_index < len(tax_inv_lines) and tax_inv_lines[inv_index] or tax_inv_lines[-1] + ref_rep_line = ref_index < len(tax_ref_lines) and tax_ref_lines[ref_index] or tax_ref_lines[-1] + + rslt_list = rslt.get(inv_rep_line.id, []) + if ref_rep_line.id not in rslt_list: + rslt_list.append(ref_rep_line.id) + rslt[inv_rep_line.id] = rslt_list + + if amount_to_match > 0: + amount_to_match -= ref_rep_line.factor_percent + ref_index += 1 + elif amount_to_match < 0: + amount_to_match += inv_rep_line.factor_percent + inv_index +=1 + else: + inv_index += 1 + if inv_index < len(tax_inv_lines): + amount_to_match = tax_inv_lines[inv_index].factor_percent + + return rslt + + def _group_tax_lines_by_repartition(self): + self.ensure_one() + rslt = {} + for tax_line in self.tax_line_ids: + rslt[tax_line.tax_repartition_line_id.id] = tax_line + return rslt + + @api.model + def _fix_refund_tax_lines(self, invoice, refund): + """ Modifies the tax_line_ids of a draft refund invoice in order to make it + match the manual tax modifications that were made on its original invoice. + """ + invoice_tax_lines_map = invoice._group_tax_lines_by_repartition() + refund_tax_lines_map = refund._group_tax_lines_by_repartition() + refund_repartition_map = self._create_refund_repartition_mapping(invoice.mapped('invoice_line_ids.invoice_line_tax_ids')) + computed_tax_values = invoice.get_taxes_values() + + for tax_data in computed_tax_values.values(): + rep_line_id = tax_data['tax_repartition_line_id'] + + matching_invoice_tax = invoice_tax_lines_map.get(rep_line_id) + + if matching_invoice_tax: + ref_rep_line_id = refund_repartition_map[rep_line_id][0] + refund_tax_line = refund_tax_lines_map.get(ref_rep_line_id) + manual_difference = matching_invoice_tax.amount - tax_data['amount'] + + if not invoice.currency_id.is_zero(manual_difference): + refund_tax_line.amount += manual_difference + + @api.model def _prepare_refund(self, invoice, date_invoice=None, date=None, description=None, journal_id=None): """ Prepare the dict of values to create the new credit note from the invoice. This method may be overridden to implement custom @@ -1602,14 +1673,6 @@ class AccountInvoice(models.Model): values['invoice_line_ids'] = self._refund_cleanup_lines(invoice.invoice_line_ids) - tax_lines = invoice.tax_line_ids - taxes_to_change = { - line.tax_id.id: line.tax_id.refund_account_id.id - for line in tax_lines.filtered(lambda l: l.tax_id.refund_account_id != l.tax_id.account_id) - } - cleaned_tax_lines = self._refund_cleanup_lines(tax_lines) - values['tax_line_ids'] = self._refund_tax_lines_account_change(cleaned_tax_lines, taxes_to_change) - if journal_id: journal = self.env['account.journal'].browse(journal_id) elif invoice['type'] == 'in_invoice': @@ -1650,6 +1713,7 @@ class AccountInvoice(models.Model): message = _("This customer invoice credit note has been created from: <a href=# data-oe-model=account.invoice data-oe-id=%d>%s</a><br>Reason: %s") % (invoice.id, invoice.number, description) else: message = _("This vendor bill credit note has been created from: <a href=# data-oe-model=account.invoice data-oe-id=%d>%s</a><br>Reason: %s") % (invoice.id, invoice.number, description) + self._fix_refund_tax_lines(invoice, refund_invoice) refund_invoice.message_post(body=message) new_invoices += refund_invoice @@ -1769,7 +1833,7 @@ class AccountInvoiceLine(models.Model): price = self.price_unit * (1 - (self.discount or 0.0) / 100.0) taxes = False if self.invoice_line_tax_ids: - taxes = self.invoice_line_tax_ids.compute_all(price, currency, self.quantity, product=self.product_id, partner=self.invoice_id.partner_id) + taxes = self.invoice_line_tax_ids.compute_all(price, currency, self.quantity, product=self.product_id, partner=self.invoice_id.partner_id, is_refund=self.invoice_id.type in ('in_refund', 'out_refund')) self.price_subtotal = price_subtotal_signed = taxes['total_excluded'] if taxes else self.quantity * price self.price_total = taxes['total_included'] if taxes else self.price_subtotal if self.invoice_id.currency_id and self.invoice_id.currency_id != self.invoice_id.company_id.currency_id: @@ -2064,26 +2128,12 @@ class AccountInvoiceTax(models.Model): 'account_id': self.account_id.id, 'account_analytic_id': self.account_analytic_id.id, 'analytic_tag_ids': self.analytic_tag_ids.ids or False, - 'tax_ids': self.tax_ids and tax.tax_ids.ids, } - @api.depends('invoice_id.invoice_line_ids') - def _compute_base_amount(self): - tax_grouped = {} - for invoice in self.mapped('invoice_id'): - tax_grouped[invoice.id] = invoice.get_taxes_values() - for tax in self: - tax.base = 0.0 - if tax.tax_id: - key = tax.tax_id.get_grouping_key(tax._prepare_invoice_tax_val()) - if tax.invoice_id and key in tax_grouped[tax.invoice_id.id]: - tax.base = tax_grouped[tax.invoice_id.id][key]['base'] - else: - _logger.warning('Tax Base Amount not computable probably due to a change in an underlying tax (%s).', tax.tax_id.name) - invoice_id = fields.Many2one('account.invoice', string='Invoice', ondelete='cascade', index=True) name = fields.Char(string='Tax Description', required=True) tax_id = fields.Many2one('account.tax', string='Tax', ondelete='restrict') + tax_repartition_line_id = fields.Many2one(string="Originating Repartition Line", comodel_name='account.tax.repartition.line') account_id = fields.Many2one('account.account', string='Tax Account', required=True, domain=[('deprecated', '=', False)]) account_analytic_id = fields.Many2one('account.analytic.account', string='Analytic account') analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags') @@ -2094,7 +2144,7 @@ class AccountInvoiceTax(models.Model): sequence = fields.Integer(help="Gives the sequence order when displaying a list of invoice tax.") company_id = fields.Many2one('res.company', string='Company', related='account_id.company_id', store=True, readonly=True) currency_id = fields.Many2one('res.currency', related='invoice_id.currency_id', store=True, readonly=True) - base = fields.Monetary(string='Base', compute='_compute_base_amount', store=True) + base = fields.Monetary(string='Base') tax_ids = fields.Many2many('account.tax', string='Affecting Base Taxes', help='Taxes affecting the tax base amount applied before this one.') diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 40526a35e70..ac5b23e0988 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -202,23 +202,16 @@ class AccountMove(models.Model): 'analytic_account_id': analytic_account_str and int(analytic_account_str) or False, } - def _find_existing_tax_line(line_ids, tax, tag_ids, analytic_account_id): + def _find_existing_tax_line(line_ids, tax_repartition_line_id, analytic_tag_ids, analytic_account_id): if tax.analytic: - return line_ids.filtered(lambda x: x.tax_line_id == tax and x.analytic_tag_ids.ids == tag_ids and x.analytic_account_id.id == analytic_account_id) - return line_ids.filtered(lambda x: x.tax_line_id == tax) + return line_ids.filtered(lambda x: x.tax_repartition_line_id.id == tax_repartition_line_id and x.analytic_tag_ids.ids == analytic_tag_ids and x.analytic_account_id.id == analytic_account_id) + return line_ids.filtered(lambda x: x.tax_repartition_line_id.id == tax_repartition_line_id) def _get_lines_to_sum(line_ids, tax, tag_ids, analytic_account_id): if tax.analytic: return line_ids.filtered(lambda x: tax in x.tax_ids and x.analytic_tag_ids.ids == tag_ids and x.analytic_account_id.id == analytic_account_id) return line_ids.filtered(lambda x: tax in x.tax_ids) - def _get_tax_account(tax, amount): - if tax.tax_exigibility == 'on_payment' and tax.cash_basis_account_id: - return tax.cash_basis_account_id - if tax.type_tax_use == 'purchase': - return tax.refund_account_id if amount < 0 else tax.account_id - return tax.refund_account_id if amount >= 0 else tax.account_id - # Cache the already computed tax to avoid useless recalculation. processed_taxes = self.env['account.tax'] @@ -245,61 +238,59 @@ class AccountMove(models.Model): to_process_taxes = (taxes - processed_taxes).filtered(lambda t: t.amount_type != 'group') processed_taxes += to_process_taxes + # Apply tags on base line + line.tag_ids = taxes.mapped('invoice_repartition_line_ids').filtered(lambda x: x.repartition_type == 'base').mapped('tag_ids') + # Process taxes. for tax in to_process_taxes: - tax_line = _find_existing_tax_line(self.line_ids, tax, parsed_key['tag_ids'], parsed_key['analytic_account_id']) lines_to_sum = _get_lines_to_sum(self.line_ids, tax, parsed_key['tag_ids'], parsed_key['analytic_account_id']) - if not lines_to_sum: - # Drop tax line because the originator tax is no longer used. - self.line_ids -= tax_line - continue - balance = sum([l.balance for l in lines_to_sum]) # Compute the tax amount one by one. quantity = len(lines_to_sum) if tax.amount_type == 'fixed' else 1 - taxes_vals = tax.compute_all(balance, - quantity=quantity, currency=line.currency_id, product=line.product_id, partner=line.partner_id) - - if tax_line: - # Update the existing tax_line. - if balance: - # Update the debit/credit amount according to the new balance. - if taxes_vals.get('taxes'): - amount = taxes_vals['taxes'][0]['amount'] - account = _get_tax_account(tax, amount) or line.account_id - tax_line.debit = amount > 0 and amount or 0.0 - tax_line.credit = amount < 0 and -amount or 0.0 - tax_line.account_id = account - else: - # Reset debit/credit in case of the originator line is temporary set to 0 in both debit/credit. - tax_line.debit = tax_line.credit = 0.0 - elif taxes_vals.get('taxes'): - # Create a new tax_line. - - amount = taxes_vals['taxes'][0]['amount'] - account = _get_tax_account(tax, amount) or line.account_id - tax_vals = taxes_vals['taxes'][0] - - name = tax_vals['name'] - line_vals = { - 'account_id': account.id, - 'name': name, - 'tax_line_id': tax_vals['id'], - 'partner_id': line.partner_id.id, - 'debit': amount > 0 and amount or 0.0, - 'credit': amount < 0 and -amount or 0.0, - 'analytic_account_id': line.analytic_account_id.id if tax.analytic else False, - 'analytic_tag_ids': line.analytic_tag_ids.ids if tax.analytic else False, - 'move_id': self.id, - 'tax_exigible': tax.tax_exigibility == 'on_invoice', - 'company_id': self.company_id.id, - 'company_currency_id': self.company_id.currency_id.id, - } - # N.B. currency_id/amount_currency are not set because if we have two lines with the same tax - # and different currencies, we have no idea which currency set on this line. - self.env['account.move.line'].new(line_vals) + taxes_vals = tax.compute_all(balance, quantity=quantity, currency=line.currency_id, product=line.product_id, partner=line.partner_id) + + if taxes_vals.get('taxes'): + for line_vals in taxes_vals['taxes']: + tax_line = _find_existing_tax_line(self.line_ids, line_vals['tax_repartition_line_id'], parsed_key['tag_ids'], parsed_key['analytic_account_id']) + if tax_line: + if not lines_to_sum: + # Drop tax line because the originator tax is no longer used. + self.line_ids -= tax_line + elif balance: + # Update the existing tax_line. + # Update the debit/credit amount according to the new balance. + amount = line_vals['amount'] + account = line_vals['account_id'] or line.account_id + tax_line.debit = amount > 0 and amount or 0.0 + tax_line.credit = amount < 0 and -amount or 0.0 + tax_line.account_id = account + else: + # Reset debit/credit in case of the originator line is temporary set to 0 in both debit/credit. + tax_line.debit = tax_line.credit = 0.0 + else: + # Create a new tax_line. + + amount = line_vals['amount'] + to_create_vals = { + 'account_id': line_vals['account_id'] or line.account_id.id, + 'name': line_vals['name'], + 'partner_id': line.partner_id.id, + 'debit': amount > 0 and amount or 0.0, + 'credit': amount < 0 and -amount or 0.0, + 'analytic_account_id': line.analytic_account_id.id if tax.analytic else False, + 'analytic_tag_ids': line.analytic_tag_ids.ids if tax.analytic else False, + 'move_id': self.id, + 'tax_exigible': tax.tax_exigibility == 'on_invoice', + 'company_id': self.company_id.id, + 'company_currency_id': self.company_id.currency_id.id, + 'tax_repartition_line_id': line_vals['tax_repartition_line_id'], + 'tag_ids': line_vals['tag_ids'], + } + # N.B. currency_id/amount_currency are not set because if we have two lines with the same tax + # and different currencies, we have no idea which currency set on this line. + self.env['account.move.line'].new(to_create_vals) # Keep record of the values used as taxes the last time this method has been run. line.tax_line_grouping_key = _build_grouping_key(line) @@ -689,7 +680,8 @@ class AccountMoveLine(models.Model): date = fields.Date(related='move_id.date', string='Date', index=True, store=True, copy=False, readonly=False) # related is required analytic_line_ids = fields.One2many('account.analytic.line', 'move_id', string='Analytic lines', oldname="analytic_lines") tax_ids = fields.Many2many('account.tax', string='Taxes', domain=['|', ('active', '=', False), ('active', '=', True)]) - tax_line_id = fields.Many2one('account.tax', string='Originator tax', ondelete='restrict') + tax_line_id = fields.Many2one('account.tax', string='Originator tax', ondelete='restrict', compute='_compute_tax_line_id', store=True) + tax_repartition_line_id = fields.Many2one(comodel_name='account.tax.repartition.line', string="Originator Tax Repartition Line", ondelete='restrict', help="Tax repartition line that caused the creation of this move line, if any") analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account', index=True) analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags') company_id = fields.Many2one('res.company', related='account_id.company_id', string='Company', store=True, readonly=True) @@ -704,6 +696,8 @@ class AccountMoveLine(models.Model): recompute_tax_line = fields.Boolean(store=False, help="Technical field used to know if the tax_ids field has been modified in the UI.") tax_line_grouping_key = fields.Char(store=False, string='Old Taxes', help="Technical field used to store the old values of fields used to compute tax lines (in account.move form view) between the moment the user changed it and the moment the ORM reflects that change in its one2many") + tag_ids = fields.Many2many(string="Tags", comodel_name='account.account.tag', ondelete='restrict', help="Tags assigned to this line by the tax creating it, if any. It determines its impact on financial reports.") + tax_audit = fields.Char(string="Tax Audit String", compute="_compute_tax_audit", store=True, help="Computed field, listing the tax grids impacted by this line, and the amount it applies to each of them.") _sql_constraints = [ ('credit_debit1', 'CHECK (credit*debit=0)', 'Wrong credit or debit value in accounting entry !'), @@ -773,12 +767,44 @@ class AccountMoveLine(models.Model): if (line.amount_currency > 0.0 and line.credit > 0.0) or (line.amount_currency < 0.0 and line.debit > 0.0): raise ValidationError(_('The amount expressed in the secondary currency must be positive when account is debited and negative when account is credited.')) + @api.depends('tax_repartition_line_id.invoice_tax_id', 'tax_repartition_line_id.refund_tax_id') + def _compute_tax_line_id(self): + """ tax_line_id is computed as the tax linked to the repartition line creating + the move. + """ + for record in self: + rep_line = record.tax_repartition_line_id + # A constraint on account.tax.repartition.line ensures both those fields are mutually exclusive + record.tax_line_id = rep_line.invoice_tax_id or rep_line.refund_tax_id + @api.depends('account_id.user_type_id') def _compute_is_unaffected_earnings_line(self): for record in self: unaffected_earnings_type = self.env.ref("account.data_unaffected_earnings") record.is_unaffected_earnings_line = unaffected_earnings_type == record.account_id.user_type_id + @api.depends('tag_ids', 'debit', 'credit', 'journal_id', 'invoice_id') + def _compute_tax_audit(self): + separator = ' ' + + for record in self: + currency = record.company_id.currency_id + audit_str = '' + for tag in record.tag_ids: + tag_amount = (tag.tax_negate and -1 or 1) * (record.journal_id.type == 'sale' and -1 or 1) * (record.invoice_id.type in ('in_refund', 'out_refund') and -1 or 1) * record.balance + + if tag.tax_report_line_ids: + #Then, the tag comes from a report line, and hence has a + or - sign (also in its name) + for report_line in tag.tax_report_line_ids: + audit_str += separator if audit_str else '' + audit_str += report_line.tag_name + ': ' + formatLang(self.env, tag_amount, currency_obj=currency) + else: + # Then, it's a financial tag (sign is always +, and never shown in tag name) + audit_str += separator if audit_str else '' + audit_str += tag.name + ': ' + formatLang(self.env, tag_amount, currency_obj=currency) + + record.tax_audit = audit_str + @api.onchange('amount_currency', 'currency_id', 'account_id') def _onchange_amount_currency(self): '''Recompute the debit/credit based on amount_currency/currency_id and date. @@ -1234,8 +1260,8 @@ class AccountMoveLine(models.Model): #Toggle the 'tax_exigible' field to False in case it is not yet given and the tax in 'tax_line_id' or one of #the 'tax_ids' is a cash based tax. taxes = False - if vals.get('tax_line_id'): - taxes = [{'tax_exigibility': self.env['account.tax'].browse(vals['tax_line_id']).tax_exigibility}] + if vals.get('tax_repartition_line_id'): + taxes = [{'tax_exigibility': self.env['account.tax.repartition.line'].browse(vals['tax_repartition_line_id']).tax_id.tax_exigibility}] if vals.get('tax_ids'): taxes = self.env['account.move.line'].resolve_2many_commands('tax_ids', vals['tax_ids']) if taxes and any([tax['tax_exigibility'] == 'on_payment' for tax in taxes]) and not vals.get('tax_exigible'): @@ -1688,7 +1714,7 @@ class AccountPartialReconcile(models.Model): 'name': line.name, 'debit': rounded_amt if rounded_amt > 0 else 0.0, 'credit': abs(rounded_amt) if rounded_amt < 0 else 0.0, - 'account_id': line.tax_line_id.cash_basis_account_id.id, + 'account_id': line.tax_repartition_line_id.account_id.id, 'analytic_account_id': line.analytic_account_id.id, 'analytic_tag_ids': line.analytic_tag_ids.ids, 'tax_line_id': line.tax_line_id.id, @@ -1697,6 +1723,8 @@ class AccountPartialReconcile(models.Model): 'currency_id': line.currency_id.id, 'move_id': newly_created_move.id, 'partner_id': line.partner_id.id, + 'tax_repartition_line_id': line.tax_repartition_line_id.id, + 'tag_ids': [(6, 0, line.tag_ids.ids)], }) if line.account_id.reconcile: #setting the account to allow reconciliation will help to fix rounding errors @@ -1720,6 +1748,8 @@ class AccountPartialReconcile(models.Model): 'currency_id': line.currency_id.id, 'amount_currency': self.amount_currency and line.currency_id.round(line.amount_currency * amount / line.balance) or 0.0, 'partner_id': line.partner_id.id, + 'tax_repartition_line_id': line.tax_repartition_line_id.id, + 'tag_ids': [(6, 0, line.tag_ids.ids)], }) self.env['account.move.line'].with_context(check_move_validity=False).create({ 'name': line.name, diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py index 6b6841dc868..eff7872f377 100644 --- a/addons/account/models/chart_template.py +++ b/addons/account/models/chart_template.py @@ -3,7 +3,7 @@ from odoo.exceptions import AccessError from odoo import api, fields, models, _ from odoo import SUPERUSER_ID -from odoo.exceptions import UserError +from odoo.exceptions import UserError, ValidationError from odoo.http import request import logging @@ -559,15 +559,20 @@ class AccountChartTemplate(models.Model): account_ref.update(account_template_ref) # writing account values after creation of accounts - for key, value in generated_tax_res['account_dict'].items(): - if value['refund_account_id'] or value['account_id'] or value['cash_basis_account_id'] or value['cash_basis_base_account_id']: + for key, value in generated_tax_res['account_dict']['account.tax'].items(): + if value['cash_basis_transition_account_id'] or value['cash_basis_base_account_id']: AccountTaxObj.browse(key).write({ - 'refund_account_id': account_ref.get(value['refund_account_id'], False), - 'account_id': account_ref.get(value['account_id'], False), - 'cash_basis_account_id': account_ref.get(value['cash_basis_account_id'], False), + 'cash_basis_transition_account_id': account_ref.get(value['cash_basis_transition_account_id'], False), 'cash_basis_base_account_id': account_ref.get(value['cash_basis_base_account_id'], False), }) + AccountTaxRepartitionLineObj = self.env['account.tax.repartition.line'] + for key, value in generated_tax_res['account_dict']['account.tax.repartition.line'].items(): + if value['account_id']: + AccountTaxRepartitionLineObj.browse(key).write({ + 'account_id': account_ref.get(value['account_id']), + }) + # Create Journals - Only done for root chart template if not self.parent_id: self.generate_journals(account_ref, company) @@ -808,18 +813,15 @@ class AccountTaxTemplate(models.Model): children_tax_ids = fields.Many2many('account.tax.template', 'account_tax_template_filiation_rel', 'parent_tax', 'child_tax', string='Children Taxes') sequence = fields.Integer(required=True, default=1, help="The sequence field is used to define order in which the tax lines are applied.") - amount = fields.Float(required=True, digits=(16, 4)) - account_id = fields.Many2one('account.account.template', string='Tax Account', ondelete='restrict', - help="Account that will be set on invoice tax lines for invoices. Leave empty to use the expense account.", oldname='account_collected_id') - refund_account_id = fields.Many2one('account.account.template', string='Tax Account on Refunds', ondelete='restrict', - help="Account that will be set on invoice tax lines for refunds. Leave empty to use the expense account.", oldname='account_paid_id') + amount = fields.Float(required=True, digits=(16, 4), default=0) description = fields.Char(string='Display on Invoices') price_include = fields.Boolean(string='Included in Price', default=False, help="Check this if the price you use on the product and invoices includes this tax.") include_base_amount = fields.Boolean(string='Affect Subsequent Taxes', default=False, help="If set, taxes which are computed after this one will be computed based on the price tax included.") analytic = fields.Boolean(string="Analytic Cost", help="If set, the amount computed by this tax will be assigned to the same analytic account as the invoice line (if any)") - tag_ids = fields.Many2many('account.account.tag', string='Account tag', help="Optional tags you may want to assign for custom reporting") + invoice_repartition_line_ids = fields.One2many(string="Repartition for Invoices", comodel_name="account.tax.repartition.line.template", inverse_name="invoice_tax_id", copy=True, help="Repartition when the tax is used on an invoice") + refund_repartition_line_ids = fields.One2many(string="Repartition for Refund Invoices", comodel_name="account.tax.repartition.line.template", inverse_name="refund_tax_id", copy=True, help="Repartition when the tax is used on a refund") tax_group_id = fields.Many2one('account.tax.group', string="Tax Group") tax_exigibility = fields.Selection( [('on_invoice', 'Based on Invoice'), @@ -828,12 +830,11 @@ class AccountTaxTemplate(models.Model): oldname='use_cash_basis', help="Based on Invoice: the tax is due as soon as the invoice is validated.\n" "Based on Payment: the tax is due as soon as the payment of the invoice is received.") - cash_basis_account_id = fields.Many2one( - 'account.account.template', - string='Tax Received Account', + cash_basis_transition_account_id = fields.Many2one( + comodel_name='account.account.template', + string="Cash Basis Transition Account", domain=[('deprecated', '=', False)], - oldname='cash_basis_account', - help='Account used as counterpart for the journal entry, for taxes eligible based on payments.') + help="Account used to transition the tax amount for cash basis taxes. It will contain the tax amount as long as the original invoice has not been reconciled ; at reconciliation, this amount cancelled on this account and put on the regular tax account.") cash_basis_base_account_id = fields.Many2one( 'account.account.template', domain=[('deprecated', '=', False)], @@ -874,11 +875,17 @@ class AccountTaxTemplate(models.Model): 'price_include': self.price_include, 'include_base_amount': self.include_base_amount, 'analytic': self.analytic, - 'tag_ids': [(6, 0, [t.id for t in self.tag_ids])], 'children_tax_ids': [(6, 0, children_ids)], 'tax_exigibility': self.tax_exigibility, } + # We add repartition lines if there are some, so that if there are none, + # default_get is called and creates the default ones properly. + if self.invoice_repartition_line_ids: + val['invoice_repartition_line_ids'] = self.invoice_repartition_line_ids.get_repartition_line_create_vals(company) + if self.refund_repartition_line_ids: + val['refund_repartition_line_ids'] = self.refund_repartition_line_ids.get_repartition_line_create_vals(company) + if self.tax_group_id: val['tax_group_id'] = self.tax_group_id.id return val @@ -894,7 +901,7 @@ class AccountTaxTemplate(models.Model): } """ ChartTemplate = self.env['account.chart.template'] - todo_dict = {} + todo_dict = {'account.tax': {}, 'account.tax.repartition.line': {}} tax_template_to_tax = {} templates_todo = list(self) @@ -903,27 +910,36 @@ class AccountTaxTemplate(models.Model): templates_todo = [] # create taxes in batch - template_vals = [] + tax_template_vals = [] for template in templates: if all(child.id in tax_template_to_tax for child in template.children_tax_ids): vals = template._get_tax_vals(company, tax_template_to_tax) - template_vals.append((template, vals)) + tax_template_vals.append((template, vals)) else: # defer the creation of this tax to the next batch templates_todo.append(template) - taxes = ChartTemplate._create_records_with_xmlid('account.tax', template_vals, company) + taxes = ChartTemplate._create_records_with_xmlid('account.tax', tax_template_vals, company) # fill in tax_template_to_tax and todo_dict - for tax, (template, vals) in zip(taxes, template_vals): + for tax, (template, vals) in zip(taxes, tax_template_vals): tax_template_to_tax[template.id] = tax.id # Since the accounts have not been created yet, we have to wait before filling these fields - todo_dict[tax.id] = { - 'account_id': template.account_id.id, - 'refund_account_id': template.refund_account_id.id, - 'cash_basis_account_id': template.cash_basis_account_id.id, + todo_dict['account.tax'][tax.id] = { + 'cash_basis_transition_account_id': template.cash_basis_transition_account_id.id, 'cash_basis_base_account_id': template.cash_basis_base_account_id.id, } + # We also have to delay the assignation of accounts to repartition lines + all_tax_rep_lines = tax.invoice_repartition_line_ids + tax.refund_repartition_line_ids + all_template_rep_lines = template.invoice_repartition_line_ids + template.refund_repartition_line_ids + for i in range(0, len(all_tax_rep_lines)): + # We assume template and tax repartition lines are in the same order + template_account = all_template_rep_lines[i].account_id + if template_account: + todo_dict['account.tax.repartition.line'][all_tax_rep_lines[i].id] = { + 'account_id': template_account.id, + } + if any(template.tax_exigibility == 'on_payment' for template in self): # When a CoA is being installed automatically and if it is creating account tax(es) whose field `Use Cash Basis`(tax_exigibility) is set to True by default # (example of such CoA's are l10n_fr and l10n_mx) then in the `Accounting Settings` the option `Cash Basis` should be checked by default. @@ -934,6 +950,79 @@ class AccountTaxTemplate(models.Model): 'account_dict': todo_dict } +# Tax Repartition Line Template + +class AccountTaxRepartitionLineTemplate(models.Model): + _name = "account.tax.repartition.line.template" + _description = "Tax Repartition Line Template" + + factor_percent = fields.Float(string="%", required=True, help="Factor to apply on the account move lines generated from this repartition line, in percents") + repartition_type = fields.Selection(string="Based On", selection=[('base', 'Base'), ('tax', 'of tax')], required=True, default='tax', help="Base on which the factor will be applied.") + account_id = fields.Many2one(string="Account", comodel_name='account.account.template', help="Account on which to post the tax amount") + invoice_tax_id = fields.Many2one(comodel_name='account.tax.template', help="The tax set to apply this repartition on invoices. Mutually exclusive with refund_tax_id") + refund_tax_id = fields.Many2one(comodel_name='account.tax.template', help="The tax set to apply this repartition on refund invoices. Mutually exclusive with invoice_tax_id") + tag_ids = fields.Many2many(string="Financial Tags", relation='account_tax_repartition_financial_tags', comodel_name='account.account.tag', copy=True, help="Additional tags that will be assigned by this repartition line for use in financial reports") + + # These last two fields are helpers used to ease the declaration of account.account.tag objects in XML. + # They are directly linked to account.tax.report.line objects, which create corresponding + and - tags + # at creation. This way, we avoid declaring + and - separately every time. + plus_report_line_ids = fields.Many2many(string="Plus Tax Report Lines", relation='account_tax_repartition_plus_report_line', comodel_name='account.tax.report.line', copy=True, help="Tax report lines whose '+' tag will be assigned to move lines by this repartition line") + minus_report_line_ids = fields.Many2many(string="Minus Report Lines", relation='account_tax_repartition_minus_report_line', comodel_name='account.tax.report.line', copy=True, help="Tax report lines whose '-' tag will be assigned to move lines by this repartition line") + + @api.model + def create(self, vals): + if vals.get('plus_report_line_ids'): + vals['plus_report_line_ids'] = self._convert_tag_syntax_to_orm(vals['plus_report_line_ids']) + + if vals.get('minus_report_line_ids'): + vals['minus_report_line_ids'] = self._convert_tag_syntax_to_orm(vals['minus_report_line_ids']) + + if vals.get('tag_ids'): + vals['tag_ids'] = self._convert_tag_syntax_to_orm(vals['tag_ids']) + + return super(AccountTaxRepartitionLineTemplate, self).create(vals) + + @api.model + def _convert_tag_syntax_to_orm(self, tags_list): + """ Repartition lines give the possibility to directly give + a list of ids to create for tags instead of a list of ORM commands. + + This function checks that tags_list uses this syntactic sugar and returns + an ORM-compliant version of it if it does. + """ + if tags_list and all(isinstance(elem, int) for elem in tags_list): + return [(6, False, tags_list)] + return tags_list + + @api.constrains('invoice_tax_id', 'refund_tax_id') + def validate_tax_template_link(self): + for record in self: + if record.invoice_tax_id and record.refund_tax_id: + raise ValidationError(_("Tax repartition line templates should apply to either invoices or refunds, not both at the same time. invoice_tax_id and refund_tax_id should not be set together.")) + + @api.constrains('plus_report_line_ids', 'minus_report_line_ids') + def validate_tags(self): + all_tax_rep_lines = self.mapped('plus_report_line_ids') + self.mapped('minus_report_line_ids') + lines_without_tag = all_tax_rep_lines.filtered(lambda x: not x.tag_name) + if lines_without_tag: + raise ValidationError(_("The following tax report lines are used in some tax repartition template though they don't generate any tag: %s . This probably means you forgot to set a tag_name on these lines.") % str(lines_without_tag.mapped('name'))) + + def get_repartition_line_create_vals(self, company): + rslt = [(5, 0, 0)] + for record in self: + tags_to_add = self.env['account.account.tag'] + tags_to_add += record.plus_report_line_ids.mapped('tag_ids').filtered(lambda x: not x.tax_negate) + tags_to_add += record.minus_report_line_ids.mapped('tag_ids').filtered(lambda x: x.tax_negate) + tags_to_add += record.tag_ids + + rslt.append((0, 0, { + 'factor_percent': record.factor_percent, + 'repartition_type': record.repartition_type, + 'tag_ids': [(6, 0, tags_to_add.ids)], + 'company_id': company.id, + })) + return rslt + # Fiscal Position Templates class AccountFiscalPositionTemplate(models.Model): diff --git a/addons/account/models/product.py b/addons/account/models/product.py index 43ea3d90b7a..aa1c2bd8922 100644 --- a/addons/account/models/product.py +++ b/addons/account/models/product.py @@ -79,4 +79,6 @@ class ProductProduct(models.Model): 'tax_ids': line.get('tax_ids', False), 'tax_line_id': line.get('tax_line_id', False), 'analytic_tag_ids': line.get('analytic_tag_ids', False), + 'tax_repartition_line_id': line.get('tax_repartition_line_id'), + 'tag_ids': line.get('tag_ids'), } diff --git a/addons/account/security/account_security.xml b/addons/account/security/account_security.xml index 10fdb27f620..48514f664ab 100644 --- a/addons/account/security/account_security.xml +++ b/addons/account/security/account_security.xml @@ -135,6 +135,13 @@ <field name="domain_force">['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])]</field> </record> + <record id="tax_report_comp_rule" model="ir.rule"> + <field name="name">Tax report multi-company</field> + <field name="model_id" ref="model_account_tax_report_line"/> + <field name="global" eval="True"/> + <field name="domain_force">[('country_id','=',user.company_id.country_id.id)]</field> + </record> + <record id="invoice_comp_rule" model="ir.rule"> <field name="name">Invoice multi-company</field> <field name="model_id" ref="model_account_invoice"/> diff --git a/addons/account/security/ir.model.access.csv b/addons/account/security/ir.model.access.csv index 2f74ad224ae..27828b3824c 100644 --- a/addons/account/security/ir.model.access.csv +++ b/addons/account/security/ir.model.access.csv @@ -86,3 +86,9 @@ access_account_incoterms_all,account.incoterms all,model_account_incoterms,,1,0, access_account_incoterms_manager,account.incoterms manager,model_account_incoterms,account.group_account_manager,1,1,1,1 access_account_journal_group_all,account.journal.group all,model_account_journal_group,,1,0,0,0 access_account_journal_group_manager,account.journal.group manager,model_account_journal_group,account.group_account_manager,1,1,1,1 +access_account_tax_repartition_line_user,account.tax repartition.line.user,model_account_tax_repartition_line,base.group_user,1,0,0,0 +access_account_tax_repartition_line_invoice,account.tax repartition.line.invoice,model_account_tax_repartition_line,account.group_account_invoice,1,0,0,0 +access_account_tax_repartition_line_manager,account.tax repartition.line.manager,model_account_tax_repartition_line,account.group_account_manager,1,1,1,1 +access_account_tax_repartition_line_template_manager,account.tax repartition.line.template.manager,model_account_tax_repartition_line_template,account.group_account_manager,1,1,1,1 +access_account_tax_report_line_invoice,account.tax.report.line.invoice,model_account_tax_report_line,account.group_account_invoice,1,0,0,0 +access_account_tax_report_line_ac_user,account.tax.report.line.ac.user,model_account_tax_report_line,account.group_account_manager,1,1,1,1 diff --git a/addons/account/tests/test_account_customer_invoice.py b/addons/account/tests/test_account_customer_invoice.py index 03ca61aecf5..b3821a989c2 100644 --- a/addons/account/tests/test_account_customer_invoice.py +++ b/addons/account/tests/test_account_customer_invoice.py @@ -166,7 +166,7 @@ class TestAccountCustomerInvoice(AccountTestUsers): invoice_line_ids=invoice_line_data )) - self.assertEquals(invoice.amount_untaxed, sum([x.base for x in invoice.tax_line_ids])) + self.assertAlmostEquals(invoice.amount_untaxed, sum([x.base for x in invoice.tax_line_ids])) def test_customer_invoice_tax_refund(self): company = self.env.user.company_id @@ -193,8 +193,32 @@ class TestAccountCustomerInvoice(AccountTestUsers): 'amount': 15.0, 'amount_type': 'percent', 'type_tax_use': 'sale', - 'account_id': tax_account.id, - 'refund_account_id': tax_refund_account.id + 'invoice_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': tax_account.id, + }), + + ], + 'refund_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': tax_refund_account.id, + }), + + ], }) invoice_line_data = [ diff --git a/addons/account/tests/test_invoice_taxes.py b/addons/account/tests/test_invoice_taxes.py index bb8dd06b617..4b0d8380f72 100644 --- a/addons/account/tests/test_invoice_taxes.py +++ b/addons/account/tests/test_invoice_taxes.py @@ -41,16 +41,16 @@ class TestInvoiceTaxes(AccountingTestCase): 'sequence': 40, }) - def _create_invoice(self, taxes_per_line): + def _create_invoice(self, taxes_per_line, inv_type='out_invoice'): ''' Create an invoice on the fly. :param taxes_per_line: A list of tuple (price_unit, account.tax recordset) ''' - self_ctx = self.env['account.invoice'].with_context(type='out_invoice') + self_ctx = self.env['account.invoice'].with_context(type=inv_type) journal_id = self_ctx._default_journal().id self_ctx = self_ctx.with_context(journal_id=journal_id) - with Form(self_ctx, view='account.invoice_form') as invoice_form: + with Form(self_ctx, view=(inv_type in ('out_invoice', 'out_refund') and 'account.invoice_form' or 'invoice_supplier_form')) as invoice_form: invoice_form.partner_id = self.env.ref('base.partner_demo') for amount, taxes in taxes_per_line: @@ -109,10 +109,10 @@ class TestInvoiceTaxes(AccountingTestCase): (100, self.percent_tax_2), ]) invoice.action_invoice_open() - self.assertRecordValues(invoice.tax_line_ids, [ - {'name': self.percent_tax_1_incl.name, 'base': 100, 'amount': 21, 'tax_ids': []}, + self.assertRecordValues(invoice.tax_line_ids.sorted(lambda x: x.amount), [ {'name': self.percent_tax_2.name, 'base': 100, 'amount': 12, 'tax_ids': []}, {'name': self.percent_tax_2.name, 'base': 121, 'amount': 14.52, 'tax_ids': [self.percent_tax_1_incl.id]}, + {'name': self.percent_tax_1_incl.name, 'base': 100, 'amount': 21, 'tax_ids': []}, ]) def test_group_of_taxes(self): @@ -134,8 +134,100 @@ class TestInvoiceTaxes(AccountingTestCase): (100, self.percent_tax_2), ]) invoice.action_invoice_open() - self.assertRecordValues(invoice.tax_line_ids, [ - {'name': self.percent_tax_1_incl.name, 'base': 100, 'amount': 21, 'tax_ids': []}, + self.assertRecordValues(invoice.tax_line_ids.sorted(lambda x: x.amount), [ {'name': self.percent_tax_2.name, 'base': 100, 'amount': 12, 'tax_ids': []}, {'name': self.percent_tax_2.name, 'base': 121, 'amount': 14.52, 'tax_ids': [self.percent_tax_1_incl.id]}, + {'name': self.percent_tax_1_incl.name, 'base': 100, 'amount': 21, 'tax_ids': []}, ]) + + def _create_tax_tag(self, tag_name): + return self.env['account.account.tag'].create({ + 'name': tag_name, + 'applicability': 'taxes', + }) + + def test_tax_repartition(self): + inv_base_tag = self._create_tax_tag('invoice_base') + inv_tax_tag_10 = self._create_tax_tag('invoice_tax_10') + inv_tax_tag_90 = self._create_tax_tag('invoice_tax_90') + ref_base_tag = self._create_tax_tag('refund_base') + ref_tax_tag = self._create_tax_tag('refund_tax') + + user_type = self.env.ref('account.data_account_type_current_assets') + account_1 = self.env['account.account'].create({'name': 'test1', 'code': 'test1', 'user_type_id': user_type.id}) + account_2 = self.env['account.account'].create({'name': 'test2', 'code': 'test2', 'user_type_id': user_type.id}) + + tax = self.env['account.tax'].create({ + 'name': "Tax with account", + 'amount_type': 'fixed', + 'type_tax_use': 'sale', + 'amount': 42, + 'invoice_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [(4, inv_base_tag.id, 0)], + }), + + (0,0, { + 'factor_percent': 10, + 'repartition_type': 'tax', + 'account_id': account_1.id, + 'tag_ids': [(4, inv_tax_tag_10.id, 0)], + }), + + (0,0, { + 'factor_percent': 90, + 'repartition_type': 'tax', + 'account_id': account_2.id, + 'tag_ids': [(4, inv_tax_tag_90.id, 0)], + }), + ], + 'refund_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [(4, ref_base_tag.id, 0)], + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': account_1.id, + 'tag_ids': [(4, ref_tax_tag.id, 0)], + }), + ], + }) + + # Test invoice repartition + invoice = self._create_invoice([(100, tax)], inv_type='out_invoice') + invoice.action_invoice_open() + invoice_move = invoice.move_id + + self.assertEqual(len(invoice_move.line_ids), 4, "There should be 4 account move lines created for the invoice: payable, base and 2 tax lines") + inv_base_line = invoice_move.line_ids.filtered(lambda x: not x.tax_repartition_line_id and x.account_id.user_type_id.type != 'receivable') + self.assertEqual(len(inv_base_line), 1, "There should be only one base line generated") + self.assertEqual(abs(inv_base_line.balance), 100, "Base amount should be 100") + self.assertEqual(inv_base_line.tag_ids, inv_base_tag, "Base line should have received base tag") + inv_tax_lines = invoice_move.line_ids.filtered(lambda x: x.tax_repartition_line_id.repartition_type == 'tax') + self.assertEqual(len(inv_tax_lines), 2, "There should be two tax lines, one for each repartition line.") + self.assertEqual(abs(inv_tax_lines.filtered(lambda x: x.account_id == account_1).balance), 4.2, "Tax line on account 1 should amount to 4.2 (10% of 42)") + self.assertEqual(inv_tax_lines.filtered(lambda x: x.account_id == account_1).tag_ids, inv_tax_tag_10, "Tax line on account 1 should have 10% tag") + self.assertEqual(abs(inv_tax_lines.filtered(lambda x: x.account_id == account_2).balance), 37.8, "Tax line on account 2 should amount to 37.8 (90% of 42)") + self.assertEqual(inv_tax_lines.filtered(lambda x: x.account_id == account_2).tag_ids, inv_tax_tag_90, "Tax line on account 2 should have 90% tag") + + # Test refund repartition + refund = self._create_invoice([(100, tax)], inv_type='out_refund') + refund.action_invoice_open() + refund_move = refund.move_id + + self.assertEqual(len(refund_move.line_ids), 3, "There should be 4 account move lines created for the refund: payable, base and tax line") + ref_base_line = refund_move.line_ids.filtered(lambda x: not x.tax_repartition_line_id and x.account_id.user_type_id.type != 'receivable') + self.assertEqual(len(ref_base_line), 1, "There should be only one base line generated") + self.assertEqual(abs(ref_base_line.balance), 100, "Base amount should be 100") + self.assertEqual(ref_base_line.tag_ids, ref_base_tag, "Base line should have received base tag") + ref_tax_line = refund_move.line_ids.filtered(lambda x: x.tax_repartition_line_id.repartition_type == 'tax') + self.assertEqual(len(ref_tax_line), 1, "There should be only one tax line") + self.assertEqual(ref_tax_line.account_id, account_1, "Tax line should have been made on account 1") + self.assertEqual(abs(ref_tax_line.balance), 42, "Tax line should have been made on account 1") + self.assertEqual(ref_tax_line.tag_ids, ref_tax_tag, "Tax line should have the right tag") diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py index 02d13111b31..67e2ae40478 100644 --- a/addons/account/tests/test_reconciliation.py +++ b/addons/account/tests/test_reconciliation.py @@ -98,10 +98,33 @@ class TestReconciliation(AccountingTestCase): 'type_tax_use': 'purchase', 'company_id': company.id, 'amount': 20, - 'account_id': self.tax_waiting_account.id, 'tax_exigibility': 'on_payment', - 'cash_basis_account_id': self.tax_final_account.id, + 'cash_basis_transition_account_id': self.tax_waiting_account.id, 'cash_basis_base_account_id': self.tax_base_amount_account.id, + 'invoice_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': self.tax_final_account.id, + }), + ], + 'refund_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': self.tax_final_account.id, + }), + ], }) def create_invoice(self, type='out_invoice', invoice_amount=50, currency_id=None): @@ -1280,7 +1303,7 @@ class TestReconciliationExec(TestReconciliation): 'account_id': self.tax_waiting_account.id, 'debit': 16.67, 'move_id': purchase_move.id, - 'tax_line_id': self.tax_cash_basis.id, + 'tax_repartition_line_id': self.tax_cash_basis.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, }) purchase_move.post() @@ -1398,7 +1421,7 @@ class TestReconciliationExec(TestReconciliation): 'account_id': tax_waiting_account10.id, 'debit': 5, 'move_id': purchase_move.id, - 'tax_line_id': tax_cash_basis10percent.id, + 'tax_repartition_line_id': tax_cash_basis10percent.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, }) AccountMoveLine.create({ 'name': 'expenseTaxed 20%', @@ -1412,7 +1435,7 @@ class TestReconciliationExec(TestReconciliation): 'account_id': self.tax_waiting_account.id, 'debit': 16.67, 'move_id': purchase_move.id, - 'tax_line_id': self.tax_cash_basis.id, + 'tax_repartition_line_id': self.tax_cash_basis.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, }) purchase_move.post() @@ -1620,9 +1643,9 @@ class TestReconciliationExec(TestReconciliation): 'account_id': self.tax_waiting_account.id, 'debit': 17094.66, 'move_id': purchase_move.id, - 'tax_line_id': self.tax_cash_basis.id, 'currency_id': self.currency_usd_id, 'amount_currency': 848.16, + 'tax_repartition_line_id': self.tax_cash_basis.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, }) purchase_payable_line0 = aml_obj.create({ 'name': 'Payable', @@ -1792,7 +1815,7 @@ class TestReconciliationExec(TestReconciliation): 'account_id': self.tax_waiting_account.id, 'debit': 17094.66, 'move_id': purchase_move.id, - 'tax_line_id': self.tax_cash_basis.id, + 'tax_repartition_line_id': self.tax_cash_basis.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, 'currency_id': self.currency_usd_id, 'amount_currency': 848.16, }) @@ -1951,7 +1974,7 @@ class TestReconciliationExec(TestReconciliation): 'account_id': tax_waiting_account10.id, 'debit': 5, 'move_id': purchase_move.id, - 'tax_line_id': tax_cash_basis10percent.id, + 'tax_repartition_line_id': tax_cash_basis10percent.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, }) AccountMoveLine.create({ 'name': 'expenseTaxed 20%', @@ -1965,7 +1988,7 @@ class TestReconciliationExec(TestReconciliation): 'account_id': self.tax_waiting_account.id, 'debit': 20, 'move_id': purchase_move.id, - 'tax_line_id': self.tax_cash_basis.id, + 'tax_repartition_line_id': self.tax_cash_basis.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, }) purchase_move.post() diff --git a/addons/account/tests/test_tax.py b/addons/account/tests/test_tax.py index d188896a72a..903699dc4d9 100644 --- a/addons/account/tests/test_tax.py +++ b/addons/account/tests/test_tax.py @@ -79,10 +79,32 @@ class TestTax(AccountTestUsers): self.tax_with_account = self.tax_model.create({ 'name': "Tax with account", 'amount_type': 'fixed', - 'account_id': some_account.id, - 'refund_account_id': some_account.id, 'amount': 0, 'sequence': 8, + 'invoice_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': some_account.id, + }), + ], + 'refund_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': some_account.id, + }), + ], }) self.bank_journal = self.env['account.journal'].search([('type', '=', 'bank'), ('company_id', '=', self.account_manager.company_id.id)])[0] self.bank_account = self.bank_journal.default_debit_account_id diff --git a/addons/account/tests/test_templates_consistency.py b/addons/account/tests/test_templates_consistency.py index 015eb5f46ad..eb6219a5446 100644 --- a/addons/account/tests/test_templates_consistency.py +++ b/addons/account/tests/test_templates_consistency.py @@ -41,6 +41,8 @@ class AccountingTestTemplConsistency(HttpCase): ''' self.check_fields_consistency('account.tax.template', 'account.tax', exceptions=['chart_template_id']) self.check_fields_consistency('account.tax', 'account.tax.template', exceptions=['company_id']) + self.check_fields_consistency('account.tax.repartition.line.template', 'account.tax.repartition.line', exceptions=['plus_report_line_ids', 'minus_report_line_ids']) + self.check_fields_consistency('account.tax.repartition.line', 'account.tax.repartition.line.template', exceptions=['tag_ids', 'country_id', 'company_id', 'sequence']) def test_fiscal_position_fields(self): '''Test fields consistency for ('account.fiscal.position', 'account.fiscal.position.template') diff --git a/addons/account/views/account_invoice_view.xml b/addons/account/views/account_invoice_view.xml index b8dd9c25e05..c575dc42560 100644 --- a/addons/account/views/account_invoice_view.xml +++ b/addons/account/views/account_invoice_view.xml @@ -351,7 +351,7 @@ <group> <div class="oe_inline"> <field name="tax_line_ids"> - <tree editable="bottom" string="Taxes"> + <tree editable="bottom" string="Taxes" create="0" delete="0"> <field name="name"/> <!--Need invisible fields for on_change to create all necessary info --> <field name="tax_id" invisible="1"/> @@ -365,6 +365,8 @@ <field name="amount_rounding" invisible="1"/> <field name="amount_total" invisible="1"/> <field name="currency_id" invisible="1" force_save="1"/> + <field name="tax_repartition_line_id" invisible="1"/> <!--Needed in order for the onchange computing the taxes to keep the repartition lines--> + <field name="base" invisible="1"/> </tree> </field> </div> @@ -631,6 +633,8 @@ <field name="amount_rounding" invisible="1"/> <field name="amount_total"/> <field name="currency_id" invisible="1" force_save="1"/> + <field name="tax_repartition_line_id" invisible="1"/> <!--Needed in order for the onchange computing the taxes to keep the repartition lines--> + <field name="base" invisible="1"/> </tree> </field> </page> diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml index 4f260ced3f5..2f88e9ea33d 100644 --- a/addons/account/views/account_move_views.xml +++ b/addons/account/views/account_move_views.xml @@ -43,6 +43,7 @@ <group string="Taxes" attrs="{'invisible': [('tax_line_id','=',False), ('tax_ids','=',[])]}"> <field name="tax_line_id" readonly="1" attrs="{'invisible': [('tax_line_id','=',False)]}"/> <field name="tax_ids" widget="many2many_tags" readonly="1" attrs="{'invisible': [('tax_ids','=',[])]}"/> + <field name="tax_audit"/> </group> <group string="Matching" attrs="{'invisible':[('full_reconcile_id','=',False), ('matched_debit_ids', '=', []),('matched_credit_ids', '=', [])]}"> <label for="full_reconcile_id"/> @@ -166,6 +167,7 @@ <field name="full_reconcile_id" position="after"> <field name="tax_line_id"/> <field name="tax_base_amount" sum="Total Base Amount"/> + <field name="tax_audit"/> </field> <field name="date_maturity" position="replace"/> </field> @@ -357,6 +359,8 @@ <field name="date_maturity" required="0" invisible="context.get('view_no_maturity', False)"/> <field name="recompute_tax_line" invisible="1" readonly="1"/> <field name="tax_line_grouping_key" invisible="1" readonly="1"/> + <field name="tax_repartition_line_id" invisible="1"/> <!--Not to be removed, we need it so that the call to new() takes it into account when autocompleting the lines of a tax--> + <field name="tag_ids" invisible="1"/> </tree> </field> <field name="narration" colspan="4" placeholder="Add an internal note..." nolabel="1" height="50"/> diff --git a/addons/account/views/account_tax_report_views.xml b/addons/account/views/account_tax_report_views.xml new file mode 100644 index 00000000000..1860df8cc08 --- /dev/null +++ b/addons/account/views/account_tax_report_views.xml @@ -0,0 +1,76 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + + <record id="account_tax_report_line_tree" model="ir.ui.view"> + <field name="name">account.tax.report.line.tree</field> + <field name="model">account.tax.report.line</field> + <field name="arch" type="xml"> + <tree create="1" delete="1"> + <field name="sequence" widget="handle"/> + <field name="name"/> + </tree> + </field> + </record> + + <record id="account_tax_report_line_form" model="ir.ui.view"> + <field name="name">account.tax.report.line.form</field> + <field name="model">account.tax.report.line</field> + <field name="arch" type="xml"> + <form> + <group> + <group> + <field name="country_id" invisible="1"/> + <field name="sequence" invisible="1"/> + <field name="name"/> + <field name="tag_name"/> + </group> + <group> + <field name="parent_id" domain="[('country_id','=',country_id)]"/> + <field name="code"/> + </group> + </group> + <group> + <field name="formula"/> + </group> + <group string="Sublines"> + <field name="children_line_ids" context="{'default_country_id': country_id}"> + <form> + <group> + <group> + <field name="country_id" invisible="1"/> + <field name="sequence" invisible="1"/> + <field name="name"/> + <field name="tag_name"/> + </group> + <group> + <field name="code"/> + </group> + </group> + <group> + <field name="formula"/> + </group> + <group string="Sublines"> + <field name="children_line_ids" context="{'form_view_ref': 'account.account_tax_report_line_form', 'default_country_id': country_id}"/> + </group> + </form> + </field> + </group> + </form> + </field> + </record> + + <record id="action_configure_tax_report" model="ir.actions.act_window"> + <field name="name">Configure Tax Report</field> + <field name="type">ir.actions.act_window</field> + <field name="res_model">account.tax.report.line</field> + <field name="view_type">form</field> + <field name="view_mode">tree,form</field> + <field name="view_id" ref="account_tax_report_line_tree"/> + </record> + + + <menuitem id="menu_configure_tax_report" name="Tax Report" parent="account.account_management_menu" action="account.action_configure_tax_report" groups="base.group_no_one"/> + + </data> +</odoo>
\ No newline at end of file diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index e8f70b89ab2..b6380bd5e5c 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -393,6 +393,10 @@ action = model.setting_init_bank_account_action() </div> <group> <field name="name"/> + <field name="applicability"/> + <field name="tax_negate" attrs="{'invisible': [('applicability', '!=', 'taxes')]}"/> + <field name="country_id" attrs="{'invisible': [('applicability', '!=', 'taxes')]}"/> + <field name="tax_report_line_ids" attrs="{'invisible': [('applicability', '!=', 'taxes')]}"/> </group> </sheet> </form> @@ -988,6 +992,22 @@ action = model.setting_init_bank_account_action() </field> </record> + <record id="tax_repartition_line_tree" model="ir.ui.view"> + <field name="name">account.tax.repartition.line.tree</field> + <field name="model">account.tax.repartition.line</field> + <field name="arch" type="xml"> + <tree editable="bottom" create="1" delete="1"> + <field name="sequence" widget="handle"/> + <field name="factor_percent" attrs="{'invisible': [('repartition_type', '=', 'base')]}"/> + <field name="repartition_type"/> + <field name="account_id" attrs="{'invisible': [('repartition_type', '=', 'base')]}" options="{'no_create': True}"/> + <field name="tag_ids" widget="many2many_tags" options="{'no_create': True}" domain="[('applicability', '=', 'taxes'), ('country_id', '=', country_id)]"/> + <field name="country_id" invisible="1"/> + <field name="company_id" invisible="1"/> + </tree> + </field> + </record> + <record id="account_tax_view_tree" model="ir.ui.view"> <field name="name">account.invoice.line.tax.search</field> <field name="model">account.tax</field> @@ -1067,35 +1087,34 @@ action = model.setting_init_bank_account_action() <group> <group> <field name="name"/> + <field name="amount_type"/> </group> <group> <field name="type_tax_use"/> + <label for="amount" attrs="{'invisible':[('amount_type','not in', ('fixed', 'percent', 'division'))]}"/> + <div attrs="{'invisible':[('amount_type','not in', ('fixed', 'percent', 'division'))]}"> + <field name="amount" class="oe_inline"/> + <span class="o_form_label oe_inline" attrs="{'invisible':[('amount_type','=','fixed')]}">%</span> + </div> </group> </group> <notebook> <page string="Definition"> - <group> - <group> - <field name="amount_type" /> - <label for="amount" attrs="{'invisible':[('amount_type','not in', ('fixed', 'percent', 'division'))]}"/> - <div attrs="{'invisible':[('amount_type','not in', ('fixed', 'percent', 'division'))]}"> - <field name="amount" class="oe_inline" /> - <span class="o_form_label oe_inline" attrs="{'invisible':[('amount_type','=','fixed')]}">%</span> - </div> + <div attrs="{'invisible': [('amount_type', '=', 'group')]}"> + <field name="country_id" invisible="1"/> + <group string="Repartition for Invoices"> + <field name="invoice_repartition_line_ids" nolabel="1" context="{'default_company_id': company_id}"/> </group> - <group attrs="{'invisible':[('amount_type','=', 'group')]}" groups="account.group_account_user"> - <field name="account_id" options="{'no_create': True}"/> - <field name="refund_account_id" options="{'no_create': True}"/> + <group string="Repartition for Credit Notes"> + <field name="refund_repartition_line_ids" nolabel="1" context="{'default_company_id': company_id}"/> </group> - </group> - <field name="children_tax_ids" - attrs="{'invisible':['|', ('amount_type','!=','group'), ('type_tax_use','=','none')]}" - domain="[('type_tax_use','in',('none',type_tax_use)), ('amount_type','!=','group')]"> + </div> + <field name="children_tax_ids" attrs="{'invisible':['|', ('amount_type','!=','group'), ('type_tax_use','=','none')]}" domain="[('type_tax_use','in',('none',type_tax_use)), ('amount_type','!=','group')]"> <tree string="Children Taxes"> - <field name="sequence" widget="handle" /> + <field name="sequence" widget="handle"/> <field name="name"/> - <field name="amount_type" /> - <field name="amount" /> + <field name="amount_type"/> + <field name="amount"/> </tree> </field> </page> @@ -1104,7 +1123,6 @@ action = model.setting_init_bank_account_action() <group> <field name="description" attrs="{'invisible':[('amount_type','=', 'group')]}"/> <field name="tax_group_id"/> - <field name="tag_ids" domain="[('applicability', '!=', 'accounts')]" widget="many2many_tags" context="{'default_applicability': 'taxes'}"/> <field name="analytic" attrs="{'invisible':[('amount_type','=', 'group')]}" groups="analytic.group_analytic_accounting" /> <field name="company_id" options="{'no_create': True}" groups="base.group_multi_company"/> </group> @@ -1113,8 +1131,8 @@ action = model.setting_init_bank_account_action() <field name="include_base_amount" attrs="{'invisible':[('amount_type','=', 'group')]}" /> <field name="hide_tax_exigibility" invisible="1"/> <field name="tax_exigibility" widget="radio" attrs="{'invisible':['|', ('amount_type','=', 'group'), ('hide_tax_exigibility', '=', False)]}" groups="account.group_account_user"/> - <field name="cash_basis_account_id" options="{'no_create': True}" attrs="{'invisible': [('tax_exigibility', '=', 'on_invoice')], 'required': [('tax_exigibility', '=', 'on_payment')]}" groups="account.group_account_user"/> <field name="cash_basis_base_account_id" options="{'no_create': True}" attrs="{'invisible': [('tax_exigibility', '=', 'on_invoice')], 'required': [('tax_exigibility', '=', 'on_payment')]}" groups="account.group_account_user"/> + <field name="cash_basis_transition_account_id" options="{'no_create': True}" attrs="{'invisible': [('tax_exigibility', '=', 'on_invoice')], 'required': [('tax_exigibility', '=', 'on_payment')]}" groups="account.group_account_user"/> </group> </group> </page> @@ -1460,10 +1478,6 @@ action = model.setting_init_bank_account_action() <span class="o_form_label oe_inline" attrs="{'invisible':[('amount_type','=','fixed')]}">%</span> </div> </group> - <group attrs="{'invisible':[('amount_type','=', 'group')]}"> - <field name="account_id" options="{'no_create': True}"/> - <field name="refund_account_id" options="{'no_create': True}"/> - </group> </group> <field name="children_tax_ids" attrs="{'invisible':['|', ('amount_type','!=','group'), ('type_tax_use','=','none')]}" @@ -1487,10 +1501,10 @@ action = model.setting_init_bank_account_action() <field name="include_base_amount" attrs="{'invisible':[('amount_type','=', 'group')]}" /> </group> <group> - <field name="tag_ids" domain="[('applicability', '!=', 'accounts')]" widget="many2many_tags" context="{'default_applicability': 'taxes'}"/> <field name="active" groups="base.group_no_one"/> <field name="tax_exigibility" widget="radio" attrs="{'invisible':[('amount_type','=', 'group')]}"/> - <field name="cash_basis_account_id" options="{'no_create': True}" attrs="{'invisible': [('tax_exigibility', '=', 'on_invoice')], 'required': [('tax_exigibility', '=', 'on_payment')]}"/> + <field name="cash_basis_base_account_id" attrs="{'invisible': [('tax_exigibility', '=', 'on_invoice')], 'required': [('tax_exigibility', '=', 'on_payment')]}"/> + <field name="cash_basis_transition_account_id" attrs="{'invisible': [('tax_exigibility', '=', 'on_invoice')], 'required': [('tax_exigibility', '=', 'on_payment')]}"/> </group> </group> </page> diff --git a/addons/account/wizard/wizard_tax_adjustments.py b/addons/account/wizard/wizard_tax_adjustments.py index 91a0edc7cf4..52d674f00fe 100644 --- a/addons/account/wizard/wizard_tax_adjustments.py +++ b/addons/account/wizard/wizard_tax_adjustments.py @@ -25,25 +25,36 @@ class TaxAdjustments(models.TransientModel): @api.multi def _create_move(self): adjustment_type = self.env.context.get('adjustment_type', (self.amount > 0.0 and 'debit' or 'credit')) - debit_vals = { - 'name': self.reason, - 'debit': abs(self.amount), - 'credit': 0.0, - 'account_id': self.debit_account_id.id, - 'tax_line_id': adjustment_type == 'debit' and self.tax_id.id or False, - } - credit_vals = { + move_line_vals = [] + + is_debit = adjustment_type == 'debit' + for tax_vals in self.tax_id.compute_all(abs(self.amount))['taxes']: + repartition_line = self.env['account.tax.repartition.line'].browse(tax_vals['tax_repartition_line_id']) + # Vals for the amls corresponding to the tax + move_line_vals.append((0, 0, { + 'name': self.reason, + 'debit': is_debit and abs(self.amount) or 0, + 'credit': not is_debit and abs(self.amount) or 0, + 'account_id': is_debit and self.debit_account_id.id or self.credit_account_id.id, + 'tax_repartition_line_id': repartition_line.id, + 'tag_ids': [(6, False, repartition_line.tag_ids.ids)], + })) + + # Vals for the counterpart line + move_line_vals.append((0, 0, { 'name': self.reason, - 'debit': 0.0, - 'credit': abs(self.amount), - 'account_id': self.credit_account_id.id, - 'tax_line_id': adjustment_type == 'credit' and self.tax_id.id or False, - } + 'debit': not is_debit and abs(self.amount) or 0, + 'credit': is_debit and abs(self.amount) or 0, + 'account_id': is_debit and self.credit_account_id.id or self.debit_account_id.id, + 'tax_repartition_line_id': False, + })) + + # Create the move vals = { 'journal_id': self.journal_id.id, 'date': self.date, 'state': 'draft', - 'line_ids': [(0, 0, debit_vals), (0, 0, credit_vals)] + 'line_ids': move_line_vals, } move = self.env['account.move'].create(vals) move.post() |
