diff --git a/addons/account/models/account_tax.py b/addons/account/models/account_tax.py
index 304d2fc07d3..905de9d56ce 100644
--- a/addons/account/models/account_tax.py
+++ b/addons/account/models/account_tax.py
@@ -3,10 +3,13 @@ from odoo import api, fields, models, _, Command
from odoo.osv import expression
from odoo.tools.float_utils import float_round
from odoo.exceptions import UserError, ValidationError
-from odoo.tools.misc import formatLang
+from odoo.tools.misc import clean_context, formatLang
from odoo.tools import frozendict, groupby
-from collections import defaultdict
+from collections import Counter, defaultdict
+from markupsafe import Markup
+
+import ast
import math
import re
@@ -80,13 +83,14 @@ class AccountTaxGroup(models.Model):
class AccountTax(models.Model):
_name = 'account.tax'
+ _inherit = ['mail.thread']
_description = 'Tax'
_order = 'sequence,id'
_check_company_auto = True
_rec_names_search = ['name', 'description', 'invoice_label']
_check_company_domain = models.check_company_domain_parent_of
- name = fields.Char(string='Tax Name', required=True, translate=True)
+ name = fields.Char(string='Tax Name', required=True, translate=True, tracking=True)
name_searchable = fields.Char(store=False, search='_search_name',
help="This dummy field lets us use another search method on the field 'name'."
"This allows more freedom on how to search the 'name' compared to 'filter_domain'."
@@ -114,16 +118,17 @@ class AccountTax(models.Model):
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), default=0.0)
+ amount = fields.Float(required=True, digits=(16, 4), default=0.0, tracking=True)
description = fields.Char(string='Description', translate=True)
invoice_label = fields.Char(string='Label on Invoices', translate=True)
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,
+ include_base_amount = fields.Boolean(string='Affect Base of Subsequent Taxes', default=False, tracking=True,
help="If set, taxes with a higher sequence than this one will be affected by it, provided they accept it.")
is_base_affected = fields.Boolean(
string="Base Affected by Previous Taxes",
default=True,
+ tracking=True,
help="If set, taxes with a lower sequence might affect this one, provided they try to do it.")
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)")
tax_group_id = fields.Many2one(
@@ -175,6 +180,8 @@ class AccountTax(models.Model):
help="The country for which this tax is applicable.",
)
country_code = fields.Char(related='country_id.code', readonly=True)
+ is_used = fields.Boolean(string="Tax used", compute='_compute_is_used')
+ repartition_lines_str = fields.Char(string="Repartition Lines", tracking=True, compute='_compute_repartition_lines_str')
@api.constrains('company_id', 'name', 'type_tax_use', 'tax_scope')
def _constrains_name(self):
@@ -201,6 +208,12 @@ class AccountTax(models.Model):
if record.tax_group_id.country_id and record.tax_group_id.country_id != record.country_id:
raise ValidationError(_("The tax group must have the same country_id as the tax using it."))
+ @api.constrains('amount_type', 'type_tax_use', 'price_include', 'include_base_amount')
+ def _constrains_fields_after_tax_is_used(self):
+ for tax in self:
+ if tax.is_used:
+ raise ValidationError(_("This tax has been used in transactions. For that reason, it is forbidden to modify this field."))
+
@api.depends('company_id.account_fiscal_country_id')
def _compute_country_id(self):
for tax in self:
@@ -225,6 +238,93 @@ class AccountTax(models.Model):
('country_id', '=', False),
], limit=1)
+ def _hook_compute_is_used(self):
+ '''
+ To be overriden to add taxed transactions in the computation of `is_used`
+ Should return a Counter containing a dictionary {record: int} where
+ the record is an account.tax object. The int should be greater than 0
+ if the tax is used in a transaction.
+ '''
+ return Counter()
+
+ def _compute_is_used(self):
+ taxes_in_transactions_ctr = (
+ Counter(dict(self.env['account.move.line']._read_group([], groupby=['tax_ids'], aggregates=['__count']))) +
+ Counter(dict(self.env['account.reconcile.model.line']._read_group([], groupby=['tax_ids'], aggregates=['__count']))) +
+ self._hook_compute_is_used()
+ )
+ for tax in self:
+ tax.is_used = bool(taxes_in_transactions_ctr[tax])
+
+ @api.depends('repartition_line_ids.account_id', 'repartition_line_ids.factor_percent', 'repartition_line_ids.use_in_tax_closing', 'repartition_line_ids.tag_ids')
+ def _compute_repartition_lines_str(self):
+ for tax in self:
+ repartition_lines_str = tax.repartition_lines_str or ""
+ if tax.is_used:
+ for repartition_line in tax.repartition_line_ids:
+ repartition_line_info = {
+ _('id'): repartition_line.id,
+ _('Factor Percent'): repartition_line.factor_percent,
+ _('Account'): repartition_line.account_id.name or _('None'),
+ _('Tax Grids'): repartition_line.tag_ids.mapped('name') or _('None'),
+ _('Use in tax closing'): _('True') if repartition_line.use_in_tax_closing else _('False'),
+ }
+ repartition_lines_str += str(repartition_line_info) + '//'
+ repartition_lines_str = repartition_lines_str.strip('//')
+ tax.repartition_lines_str = repartition_lines_str
+
+ def _message_log_repartition_lines(self, old_value_str, new_value_str):
+ self.ensure_one()
+ if not self.is_used:
+ return
+
+ old_values = old_value_str.split('//')
+ new_values = new_value_str.split('//')
+
+ kwargs = {}
+ for old_value, new_value in zip(old_values, new_values):
+ if old_value != new_value:
+ old_value = ast.literal_eval(old_value)
+ new_value = ast.literal_eval(new_value)
+ diff_keys = [key for key in old_value if old_value[key] != new_value[key]]
+ repartition_line = self.env['account.tax.repartition.line'].search([('id', '=', new_value['id'])])
+ body = Markup("{type} {rep} {seq}:
""").format(old=old_value[diff_key], new=new_value[diff_key], diff=diff_key)
+ for diff_key in diff_keys]
+ )
+ )
+ kwargs['body'] = body
+ super()._message_log(**kwargs)
+
+ def _message_log(self, **kwargs):
+ # OVERRIDE _message_log
+ # We only log the modification of the tracked fields if the tax is
+ # currently used in transactions. We remove the `repartition_lines_str`
+ # from tracked value to avoid having it logged twice (once in the raw
+ # string format and one in the nice formatted way thanks to
+ # `_message_log_repartition_lines`)
+
+ self.ensure_one()
+
+ if self.is_used:
+ repartition_line_str_field_id = self.env['ir.model.fields']._get('account.tax', 'repartition_lines_str').id
+ for tracked_value_id in kwargs['tracking_value_ids']:
+ if tracked_value_id[2]['field_id'] == repartition_line_str_field_id:
+ kwargs['tracking_value_ids'].remove(tracked_value_id)
+ self._message_log_repartition_lines(tracked_value_id[2]['old_value_char'], tracked_value_id[2]['new_value_char'])
+
+ return super()._message_log(**kwargs)
+
@api.depends('company_id')
def _compute_invoice_repartition_line_ids(self):
for tax in self:
@@ -359,7 +459,14 @@ class AccountTax(models.Model):
@api.model_create_multi
def create(self, vals_list):
- return super().create([self._sanitize_vals(vals) for vals in vals_list])
+ context = clean_context(self.env.context)
+ context.update({
+ 'mail_create_nosubscribe': True, # At create or message_post, do not subscribe the current user to the record thread
+ 'mail_auto_subscribe_no_notify': True, # Do no notify users set as followers of the mail thread
+ 'mail_create_nolog': True, # At create, do not log the automatic ‘ created’ message
+ })
+ taxes = super(AccountTax, self.with_context(context)).create([self._sanitize_vals(vals) for vals in vals_list])
+ return taxes
def write(self, vals):
return super().write(self._sanitize_vals(vals))
@@ -1382,6 +1489,23 @@ class AccountTaxRepartitionLine(models.Model):
tag_ids_domain = fields.Binary(string="tag domain", help="Dynamic domain used for the tag that can be set on tax", compute="_compute_tag_ids_domain")
+ @api.model_create_multi
+ def create(self, vals):
+ tax_ids = list(set([line.get('tax_id') for line in vals])) # Sorted
+ taxes = self.env['account.tax'].search_fetch([('id', 'in', tax_ids)], ['name'], order='id ASC')
+ tax_dict = dict(zip(tax_ids, taxes))
+ for line in vals:
+ tax = tax_dict.get(line.get('tax_id'))
+ if tax and tax.is_used:
+ raise ValidationError(_("The tax named {} has already been used, you cannot add nor delete its tax repartition lines.").format(tax.name))
+ return super().create(vals)
+
+ def unlink(self):
+ for repartition_line in self:
+ if repartition_line.tax_id.is_used:
+ raise ValidationError(_("The tax named {} has already been used, you cannot add nor delete its tax repartition lines.").format(repartition_line.tax_id.name))
+ return super().unlink()
+
@api.depends('company_id.multi_vat_foreign_country_ids', 'company_id.account_fiscal_country_id')
def _compute_tag_ids_domain(self):
for rep_line in self:
diff --git a/addons/account/tests/test_account_tax.py b/addons/account/tests/test_account_tax.py
index 94bd2a1658f..4285c7a5786 100644
--- a/addons/account/tests/test_account_tax.py
+++ b/addons/account/tests/test_account_tax.py
@@ -1,7 +1,9 @@
# -*- coding: utf-8 -*-
+
+from odoo import Command
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged
-from odoo.exceptions import UserError
+from odoo.exceptions import UserError, ValidationError
@tagged('post_install', '-at_install')
@@ -28,3 +30,98 @@ class TestAccountTax(AccountTestInvoicingCommon):
with self.assertRaises(UserError), self.cr.savepoint():
self.company_data['default_tax_sale'].company_id = self.company_data_2['company']
+
+ def test_tax_is_used_when_in_transactions(self):
+ ''' Ensures that a tax is set to used when it is part of some transactions '''
+
+ # Account.move is one type of transaction
+ tax_invoice = self.env['account.tax'].create({
+ 'name': 'test_is_used_invoice',
+ 'amount': '100',
+ })
+
+ self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'date': '2023-01-01',
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'invoice_line',
+ 'quantity': 1.0,
+ 'price_unit': 100.0,
+ 'tax_ids': [Command.set(tax_invoice.ids)],
+ }),
+ ],
+ })
+ tax_invoice.invalidate_model(fnames=['is_used'])
+ self.assertTrue(tax_invoice.is_used)
+
+ # Account.reconcile is another of transaction
+ tax_reconciliation = self.env['account.tax'].create({
+ 'name': 'test_is_used_reconcilition',
+ 'amount': '100',
+ })
+ self.env['account.reconcile.model'].create({
+ 'name': "test_tax_is_used",
+ 'rule_type': 'writeoff_suggestion',
+ 'auto_reconcile': False,
+ 'line_ids': [Command.create({
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'tax_ids': [Command.set(tax_reconciliation.ids)],
+ })],
+ })
+ tax_reconciliation.invalidate_model(fnames=['is_used'])
+ self.assertTrue(tax_reconciliation.is_used)
+
+ def test_tax_is_used_restrictions(self):
+ ''' You should not be able to modify some values of a used tax '''
+
+ tax = self.env['account.tax'].create({
+ 'name': 'test_is_used',
+ 'amount': '100',
+ })
+
+ # A newly created tax should not be used
+ self.assertFalse(tax.is_used)
+
+ self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'date': '2023-01-01',
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'invoice_line',
+ 'quantity': 1.0,
+ 'price_unit': 100.0,
+ 'tax_ids': [Command.set(tax.ids)],
+ }),
+ ],
+ })
+ tax.invalidate_model(fnames=['is_used'])
+
+ # amount_type
+ with self.assertRaises(ValidationError), self.cr.savepoint():
+ tax.amount_type = 'fixed'
+
+ # type_tax_use
+ with self.assertRaises(ValidationError), self.cr.savepoint():
+ tax.type_tax_use = 'purchase'
+
+ # price_include
+ with self.assertRaises(ValidationError), self.cr.savepoint():
+ tax.price_include = True
+
+ # include_base_amount
+ with self.assertRaises(ValidationError), self.cr.savepoint():
+ tax.include_base_amount = True
+
+ # add repartition lines
+ with self.assertRaises(ValidationError), self.cr.savepoint():
+ self.env['account.tax.repartition.line'].create([
+ {'tax_id': tax.id, 'document_type': 'invoice', 'repartition_type': 'tax', 'factor': -100},
+ {'tax_id': tax.id, 'document_type': 'refund', 'repartition_type': 'tax', 'factor': -100},
+ ])
+
+ # remove repartition lines
+ with self.assertRaises(ValidationError), self.cr.savepoint():
+ invoice_repartition_lines = tax.invoice_repartition_line_ids
+ for rep_line in invoice_repartition_lines:
+ rep_line.unlink()
diff --git a/addons/account/views/account_tax_views.xml b/addons/account/views/account_tax_views.xml
index f58d3179d27..4a33212fde4 100644
--- a/addons/account/views/account_tax_views.xml
+++ b/addons/account/views/account_tax_views.xml
@@ -37,7 +37,7 @@
account.tax.repartition.line
-
+
@@ -140,11 +140,12 @@
-
+
-
+
+