Skip to content

Commit

Permalink
[IMP] account: Logging and tracking of some fields.
Browse files Browse the repository at this point in the history
Problem
---------
All fields in the tax definition are currently modifiable even after the
tax has been used. This raises some issues in tax reports and make
investigations difficult when things go wrong in tax report.

Objective
---------
Once an account tax has been used:
 - Some fields in the account tax should not be modifiable anymore to
 avoid tax report issues;
 - Other fields should be logged when modified to ease debugging and
 investigations.

Solution
---------
1. Add a logger to the tax form to track the modification of some fields
2. Track whether the task is being used in transactions or not. This is
done thanks to a compute field. In order to be flexible, the compute
function uses a hook function that other modules can override to easily
add transactions to computation of the field.
3. Make the fields that should not be modified anymore readonly when the
tax is being used.
4. In order to track the repartition line values, a tracked computed
field that stores the relevant value in a string is used.

Task-3450002

Part-of: odoo#130403
  • Loading branch information
aboo-odoo committed Oct 20, 2023
1 parent 3895611 commit 8d77045
Show file tree
Hide file tree
Showing 12 changed files with 343 additions and 12 deletions.
136 changes: 130 additions & 6 deletions addons/account/models/account_tax.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
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

Expand Down Expand Up @@ -80,13 +83,14 @@ def _check_misconfigured_tax_groups(self, company, countries):

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'."
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Expand All @@ -201,6 +208,12 @@ def validate_tax_group_id(self):
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:
Expand All @@ -225,6 +238,93 @@ def _compute_tax_group_id(self):
('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("<b>{type}</b> {rep} {seq}:<ul class='mb-0 ps-4'>{changes}</ul>").format(
type=repartition_line.document_type.capitalize(),
rep=_('repartition line'),
seq=repartition_line.sequence + 1,
changes=Markup().join(
[Markup("""
<li>
<span class='o-mail-Message-trackingOld me-1 px-1 text-muted fw-bold'>{old}</span>
<i class='o-mail-Message-trackingSeparator fa fa-long-arrow-right mx-1 text-600'/>
<span class='o-mail-Message-trackingNew me-1 fw-bold text-info'>{new}</span>
<span class='o-mail-Message-trackingField ms-1 fst-italic text-muted'>({diff})</span>
</li>""").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:
Expand Down Expand Up @@ -359,7 +459,14 @@ def _sanitize_vals(self, vals):

@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 ‘<Document> 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))
Expand Down Expand Up @@ -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:
Expand Down
99 changes: 98 additions & 1 deletion addons/account/tests/test_account_tax.py
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -28,3 +30,98 @@ def test_changing_tax_company(self):

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()
15 changes: 10 additions & 5 deletions addons/account/views/account_tax_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
<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="sequence" widget="handle" column_invisible="parent.is_used"/>
<field name="factor_percent" invisible="repartition_type == 'base'"/>
<field name="repartition_type"/>
<field name="account_id" invisible="repartition_type == 'base'" options="{'no_create': True}"/>
Expand Down Expand Up @@ -140,11 +140,12 @@
<group>
<field name="name"/>
<field name="description"/>
<field name="amount_type"/>
<field name="amount_type" readonly="is_used"/>
<field name="active" widget="boolean_toggle"/>
</group>
<group>
<field name="type_tax_use"/>
<field name="is_used" invisible="1"/>
<field name="type_tax_use" readonly="is_used"/>
<field name="tax_scope"/>
<label for="amount" invisible="amount_type not in ('fixed', 'percent', 'division')"/>
<div invisible="amount_type not in ('fixed', 'percent', 'division')">
Expand Down Expand Up @@ -183,8 +184,8 @@
<field name="country_id" required="True"/>
</group>
<group name="advanced_booleans">
<field name="price_include" invisible="amount_type == 'group'" />
<field name="include_base_amount" invisible="amount_type == 'group'" />
<field name="price_include" invisible="amount_type == 'group'" readonly="is_used"/>
<field name="include_base_amount" invisible="amount_type == 'group'" readonly="is_used"/>
<field name="is_base_affected"
invisible="amount_type == 'group' or price_include"
groups="base.group_no_one"/>
Expand All @@ -196,6 +197,10 @@
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" widget="mail_followers"/>
<field name="message_ids" widget="mail_thread"/>
</div>
</form>
</field>
</record>
Expand Down
1 change: 1 addition & 0 deletions addons/hr_expense/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from . import account_move
from . import account_move_line
from . import account_payment
from . import account_tax
from . import hr_department
from . import hr_expense
from . import product_template
Expand Down
15 changes: 15 additions & 0 deletions addons/hr_expense/models/account_tax.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-

from collections import Counter
from odoo import models


class AccountTax(models.Model):
_inherit = "account.tax"

def _hook_compute_is_used(self):
# OVERRIDE in order to count the usage of taxes in expenses

taxes_in_transactions_ctr = Counter(dict(self.env['hr.expense']._read_group([], groupby=['tax_ids'], aggregates=['__count'])))

return super()._hook_compute_is_used() + taxes_in_transactions_ctr
1 change: 1 addition & 0 deletions addons/hr_expense/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
from . import test_expenses_access_rights
from . import test_expenses_mail_import
from . import test_expenses_multi_company
from . import test_expenses_tax
Loading

0 comments on commit 8d77045

Please sign in to comment.