From da019b62804fc992c529a70a6d8cb44b12b41cce Mon Sep 17 00:00:00 2001 From: "Ricardo Gomes Rodrigues (rigr)" Date: Sun, 5 Mar 2023 14:50:39 +0000 Subject: [PATCH] [FIX] account: add account move's name into the fields used by the hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, it is possible to resequence account moves which are hashed. This should not be the case. Therefore, we are adding the name of the move into the list of hashed fields. However, since we are changing the hashing algorithm by including a new field in its computation, we must add a versioning system to make sure we don't break the integrity (data inalterability) report. In practice, this means that prior to this commit, all hashed moves used the fields of v1, and moves after this commit will use v2 (which adds the name into the list of hashed fields). Thus, whenever we generate the integrity report, we will run the v1 algorithm, and if it a potential corrupted move is found, we will switch to v2 and check again. If it also fails, this means the hash is indeed corrupted. task-id 3102481 closes odoo/odoo#114376 X-original-commit: cb76725b071d89a9f3e81861f19d073138741b58 Signed-off-by: William André (wan) Signed-off-by: Ricardo Gomes Rodrigues (rigr) --- addons/account/i18n/account.pot | 3 +- addons/account/models/account_move.py | 78 ++++--- addons/account/models/account_move_line.py | 33 ++- addons/account/models/company.py | 8 +- addons/account/tests/__init__.py | 1 + .../tests/test_account_inalterable_hash.py | 190 ++++++++++++++++++ 6 files changed, 281 insertions(+), 32 deletions(-) create mode 100644 addons/account/tests/test_account_inalterable_hash.py diff --git a/addons/account/i18n/account.pot b/addons/account/i18n/account.pot index ce39a9b040c..9f22fa78d67 100644 --- a/addons/account/i18n/account.pot +++ b/addons/account/i18n/account.pot @@ -15494,8 +15494,7 @@ msgstr "" #: code:addons/account/models/account_move_line.py:0 #, python-format msgid "" -"You cannot edit the following fields due to restrict mode being activated on" -" the journal: %s." +"You cannot edit the following fields: %s.\nThe following entries are already hashed:\n%s" msgstr "" #. module: account diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index cd2028f4cbf..4a682a6db1f 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -34,9 +34,7 @@ from odoo.tools import ( _logger = logging.getLogger(__name__) -#forbidden fields -INTEGRITY_HASH_MOVE_FIELDS = ('date', 'journal_id', 'company_id') -INTEGRITY_HASH_LINE_FIELDS = ('debit', 'credit', 'account_id', 'partner_id') +MAX_HASH_VERSION = 2 TYPE_REVERSE_MAP = { 'entry': 'entry', @@ -81,7 +79,7 @@ class AccountMove(models.Model): # === Accounting fields === # name = fields.Char( string='Number', - compute='_compute_name', readonly=False, store=True, + compute='_compute_name', inverse='_inverse_name', readonly=False, store=True, copy=False, tracking=True, index='trigram', @@ -343,6 +341,7 @@ class AccountMove(models.Model): copy=False, help="The payment reference to set on journal items.", tracking=True, + compute='_compute_payment_reference', inverse='_inverse_payment_reference', store=True, readonly=False, ) display_qr_code = fields.Boolean( string="Display QR-code", @@ -587,6 +586,15 @@ class AccountMove(models.Model): # COMPUTE METHODS # ------------------------------------------------------------------------- + def _compute_payment_reference(self): + for move in self.filtered(lambda m: ( + m.state == 'posted' + and m.move_type == 'out_invoice' + and not m.payment_reference + )): + move.payment_reference = move._get_invoice_computed_reference() + self._inverse_payment_reference() + @api.depends('invoice_date', 'company_id') def _compute_date(self): for move in self: @@ -708,6 +716,7 @@ class AccountMove(models.Model): move._set_next_sequence() self.filtered(lambda m: not m.name).name = '/' + self._inverse_name() @api.depends('journal_id', 'date') def _compute_highest_name(self): @@ -998,7 +1007,6 @@ class AccountMove(models.Model): 'discount_date': invoice_payment_terms.get('discount_date'), 'discount_balance': invoice_payment_terms.get('discount_balance') or 0.0, 'discount_amount_currency': invoice_payment_terms.get('discount_amount_currency') or 0.0, - } if multiple_installments: values['name'] = f'{values["name"]} installment #{i + 1}'.lstrip() @@ -1017,7 +1025,6 @@ class AccountMove(models.Model): })] = { 'balance': invoice.amount_total_signed, 'amount_currency': invoice.amount_total_in_currency_signed, - 'name': invoice.payment_reference or '', } def _compute_payments_widget_to_reconcile_info(self): @@ -1547,6 +1554,16 @@ class AccountMove(models.Model): if invoice.journal_id.currency_id and invoice.journal_id.currency_id != invoice.currency_id: self.env.add_to_compute(self._fields['journal_id'], invoice) + def _inverse_payment_reference(self): + self.line_ids._conditional_add_to_compute('name', lambda line: ( + line.display_type == 'payment_term' + )) + + def _inverse_name(self): + self._conditional_add_to_compute('payment_reference', lambda move: ( + move.name and move.name != '/' + )) + # ------------------------------------------------------------------------- # ONCHANGE METHODS # ------------------------------------------------------------------------- @@ -2093,7 +2110,6 @@ class AccountMove(models.Model): def existing(): return { move: { - 'payment_reference': move.payment_reference, 'commercial_partner_id': move.commercial_partner_id, } for move in container['records'].filtered(lambda m: m.is_invoice(True)) @@ -2107,8 +2123,6 @@ class AccountMove(models.Model): after = existing() for move in after: - if changed('payment_reference'): - move.line_ids.filtered(lambda l: l.display_type == 'payment_term').name = after[move]['payment_reference'] if changed('commercial_partner_id'): move.line_ids.partner_id = after[move]['commercial_partner_id'] @@ -2234,8 +2248,8 @@ class AccountMove(models.Model): return True self._sanitize_vals(vals) for move in self: - if (move.restrict_mode_hash_table and move.state == "posted" and set(vals).intersection(INTEGRITY_HASH_MOVE_FIELDS)): - raise UserError(_("You cannot edit the following fields due to restrict mode being activated on the journal: %s.") % ', '.join(INTEGRITY_HASH_MOVE_FIELDS)) + if (move.restrict_mode_hash_table and move.state == "posted" and set(vals).intersection(move._get_integrity_hash_fields())): + raise UserError(_("You cannot edit the following fields due to restrict mode being activated on the journal: %s.") % ', '.join(move._get_integrity_hash_fields())) if (move.restrict_mode_hash_table and move.inalterable_hash and 'inalterable_hash' in vals) or (move.secure_sequence_number and 'secure_sequence_number' in vals): raise UserError(_('You cannot overwrite the values ensuring the inalterability of the accounting.')) if (move.posted_before and 'journal_id' in vals and move.journal_id.id != vals['journal_id']): @@ -2284,6 +2298,7 @@ class AccountMove(models.Model): # Hash the move if vals.get('state') == 'posted': + self.flush_recordset() # Ensure that the name is correctly computed before it is used to generate the hash for move in self.filtered(lambda m: m.restrict_mode_hash_table and not(m.secure_sequence_number or m.inalterable_hash)).sorted(lambda m: (m.date, m.ref or '', m.id)): new_number = move.journal_id.secure_sequence_id.next_by_id() res |= super(AccountMove, move).write({ @@ -2669,6 +2684,18 @@ class AccountMove(models.Model): # HASH # ------------------------------------------------------------------------- + def _get_integrity_hash_fields(self): + # Use the latest hash version by default, but keep the old one for backward compatibility when generating the integrity report. + hash_version = self._context.get('hash_version', MAX_HASH_VERSION) + if hash_version == 1: + return ['date', 'journal_id', 'company_id'] + elif hash_version == MAX_HASH_VERSION: + return ['name', 'date', 'journal_id', 'company_id'] + raise NotImplementedError(f"hash_version={hash_version} doesn't exist") + + def _get_integrity_hash_fields_and_subfields(self): + return self._get_integrity_hash_fields() + [f'line_ids.{subfield}' for subfield in self.line_ids._get_integrity_hash_fields()] + def _get_new_hash(self, secure_seq_number): """ Returns the hash to write on journal entries when they get posted""" self.ensure_one() @@ -2692,6 +2719,8 @@ class AccountMove(models.Model): hash_string = sha256((previous_hash + self.string_to_hash).encode('utf-8')) return hash_string.hexdigest() + @api.depends(lambda self: self._get_integrity_hash_fields_and_subfields()) + @api.depends_context('hash_version') def _compute_string_to_hash(self): def _getattrstring(obj, field_str): field_value = obj[field_str] @@ -2701,11 +2730,11 @@ class AccountMove(models.Model): for move in self: values = {} - for field in INTEGRITY_HASH_MOVE_FIELDS: + for field in move._get_integrity_hash_fields(): values[field] = _getattrstring(move, field) for line in move.line_ids: - for field in INTEGRITY_HASH_LINE_FIELDS: + for field in line._get_integrity_hash_fields(): k = 'line_%d_%s' % (line.id, field) values[k] = _getattrstring(line, field) #make the json serialization canonical @@ -3423,16 +3452,6 @@ class AccountMove(models.Model): if p not in invoice.sudo().message_partner_ids ]) - # Compute 'ref' for 'out_invoice'. - if invoice.move_type == 'out_invoice' and not invoice.payment_reference: - to_write = { - 'payment_reference': invoice._get_invoice_computed_reference(), - 'line_ids': [] - } - for line in invoice.line_ids.filtered(lambda line: line.account_id.account_type in ('asset_receivable', 'liability_payable')): - to_write['line_ids'].append((1, line.id, {'name': to_write['payment_reference']})) - invoice.write(to_write) - if ( invoice.is_sale_document() and invoice.journal_id.sale_activity_type_id @@ -4143,6 +4162,19 @@ class AccountMove(models.Model): # else, attachments with 'res_field' get excluded return res | self.env['account.move.send']._get_linked_attachments(self) + # ------------------------------------------------------------------------- + # TOOLING + # ------------------------------------------------------------------------- + + def _conditional_add_to_compute(self, fname, condition): + field = self._fields[fname] + to_reset = self.filtered(lambda move: + condition(move) + and not self.env.is_protected(field, move) + ) + to_reset.invalidate_recordset([fname]) + self.env.add_to_compute(field, to_reset) + # ------------------------------------------------------------------------- # HOOKS # ------------------------------------------------------------------------- diff --git a/addons/account/models/account_move_line.py b/addons/account/models/account_move_line.py index 9a6715774cf..e6d5916007e 100644 --- a/addons/account/models/account_move_line.py +++ b/addons/account/models/account_move_line.py @@ -11,7 +11,7 @@ from odoo.tools import frozendict, formatLang, format_date, float_is_zero, Query from odoo.tools.sql import create_index from odoo.addons.web.controllers.utils import clean_action -INTEGRITY_HASH_LINE_FIELDS = ('debit', 'credit', 'account_id', 'partner_id') +from odoo.addons.account.models.account_move import MAX_HASH_VERSION class AccountMoveLine(models.Model): @@ -461,11 +461,14 @@ class AccountMoveLine(models.Model): @api.depends('product_id', 'journal_id') def _compute_name(self): for line in self: + if line.display_type == 'payment_term': + if line.move_id.payment_reference: + line.name = line.move_id.payment_reference + elif not line.name: + line.name = '' + continue if not line.product_id or line.display_type in ('line_section', 'line_note'): continue - # YTI : Coming from conflicting onchange from account.move - # for line in self.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')): - # line.name = self.payment_reference or '' if line.partner_id.lang: product = line.product_id.with_context(lang=line.partner_id.lang) else: @@ -1403,6 +1406,17 @@ class AccountMoveLine(models.Model): if account_to_write and account_to_write.deprecated: raise UserError(_('You cannot use a deprecated account.')) + inalterable_fields = set(self._get_integrity_hash_fields()).union({'inalterable_hash', 'secure_sequence_number'}) + hashed_moves = self.move_id.filtered('inalterable_hash') + violated_fields = set(vals) & inalterable_fields + if hashed_moves and violated_fields: + raise UserError(_( + "You cannot edit the following fields: %s.\n" + "The following entries are already hashed:\n%s", + ', '.join(f['string'] for f in self.fields_get(violated_fields).values()), + '\n'.join(hashed_moves.mapped('name')), + )) + line_to_write = self vals = self._sanitize_vals(vals) for line in self: @@ -1411,8 +1425,6 @@ class AccountMoveLine(models.Model): continue if line.parent_state == 'posted': - if line.move_id.restrict_mode_hash_table and set(vals).intersection(INTEGRITY_HASH_LINE_FIELDS): - raise UserError(_("You cannot edit the following fields due to restrict mode being activated on the journal: %s.") % ', '.join(INTEGRITY_HASH_LINE_FIELDS)) if any(key in vals for key in ('tax_ids', 'tax_line_id')): raise UserError(_('You cannot modify the taxes related to a posted journal item, you should reset the journal entry to draft to do so.')) @@ -2506,6 +2518,15 @@ class AccountMoveLine(models.Model): # MISC # ------------------------------------------------------------------------- + def _get_integrity_hash_fields(self): + # Use the new hash version by default, but keep the old one for backward compatibility when generating the integrity report. + hash_version = self._context.get('hash_version', MAX_HASH_VERSION) + if hash_version == 1: + return ['debit', 'credit', 'account_id', 'partner_id'] + elif hash_version == MAX_HASH_VERSION: + return ['name', 'debit', 'credit', 'account_id', 'partner_id'] + raise NotImplementedError(f"hash_version={hash_version} doesn't exist") + def _reconciled_lines(self): ids = [] for aml in self.filtered('reconciled'): diff --git a/addons/account/models/company.py b/addons/account/models/company.py index 0ab69981c34..bc307542fa2 100644 --- a/addons/account/models/company.py +++ b/addons/account/models/company.py @@ -8,6 +8,7 @@ from odoo.exceptions import ValidationError, UserError, RedirectWarning from odoo.tools.mail import is_html_empty from odoo.tools.misc import format_date from odoo.tools.float_utils import float_round, float_is_zero +from odoo.addons.account.models.account_move import MAX_HASH_VERSION MONTH_SELECTION = [ @@ -639,8 +640,13 @@ class ResCompany(models.Model): previous_hash = u'' start_move_info = [] hash_corrupted = False + current_hash_version = 1 for move in moves: - if move.inalterable_hash != move._compute_hash(previous_hash=previous_hash): + computed_hash = move.with_context(hash_version=current_hash_version)._compute_hash(previous_hash=previous_hash) + while move.inalterable_hash != computed_hash and current_hash_version < MAX_HASH_VERSION: + current_hash_version += 1 + computed_hash = move.with_context(hash_version=current_hash_version)._compute_hash(previous_hash=previous_hash) + if move.inalterable_hash != computed_hash: rslt.update({'msg_cover': _('Corrupted data on journal entry with id %s.', move.id)}) results_by_journal['results'].append(rslt) hash_corrupted = True diff --git a/addons/account/tests/__init__.py b/addons/account/tests/__init__.py index 7621da3623a..c5e7dfc81eb 100644 --- a/addons/account/tests/__init__.py +++ b/addons/account/tests/__init__.py @@ -9,6 +9,7 @@ from . import test_account_move_in_refund from . import test_account_move_entry from . import test_account_move_date_algorithm from . import test_invoice_tax_totals +from . import test_account_inalterable_hash from . import test_account_journal from . import test_account_account from . import test_account_tax diff --git a/addons/account/tests/test_account_inalterable_hash.py b/addons/account/tests/test_account_inalterable_hash.py new file mode 100644 index 00000000000..31995f039eb --- /dev/null +++ b/addons/account/tests/test_account_inalterable_hash.py @@ -0,0 +1,190 @@ +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.models import Model +from odoo.tests import tagged +from odoo import fields +from odoo.exceptions import UserError +from odoo.tools import format_date + + +@tagged('post_install', '-at_install') +class TestAccountMoveInalterableHash(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + def test_account_move_inalterable_hash(self): + """Test that we cannot alter a field used for the computation of the inalterable hash""" + self.company_data['default_journal_sale'].restrict_mode_hash_table = True + move = self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000], post=True) + + with self.assertRaisesRegex(UserError, "You cannot overwrite the values ensuring the inalterability of the accounting."): + move.inalterable_hash = 'fake_hash' + with self.assertRaisesRegex(UserError, "You cannot overwrite the values ensuring the inalterability of the accounting."): + move.secure_sequence_number = 666 + with self.assertRaisesRegex(UserError, "You cannot edit the following fields due to restrict mode being activated.*"): + move.name = "fake name" + with self.assertRaisesRegex(UserError, "You cannot edit the following fields due to restrict mode being activated.*"): + move.date = fields.Date.from_string('2023-01-02') + with self.assertRaisesRegex(UserError, "You cannot edit the following fields due to restrict mode being activated.*"): + move.company_id = 666 + with self.assertRaisesRegex(UserError, "You cannot edit the following fields due to restrict mode being activated.*"): + move.write({ + 'company_id': 666, + 'date': fields.Date.from_string('2023-01-03') + }) + + with self.assertRaisesRegex(UserError, "You cannot edit the following fields.*Account.*"): + move.line_ids[0].account_id = move.line_ids[1]['account_id'] + with self.assertRaisesRegex(UserError, "You cannot edit the following fields.*Partner.*"): + move.line_ids[0].partner_id = 666 + + # The following fields are not part of the hash so they can be modified + move.ref = "bla" + move.line_ids[0].date_maturity = fields.Date.from_string('2023-01-02') + + def test_account_move_hash_integrity_report(self): + """Test the hash integrity report""" + moves = ( + self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000, 2000]) + | self.init_invoice("out_invoice", self.partner_b, "2023-01-02", amounts=[1000, 2000]) + ) + moves.action_post() + + # No records to be hashed because the restrict mode is not activated yet + integrity_check = moves.company_id._check_hash_integrity()['results'][0] # First journal + self.assertEqual(integrity_check['msg_cover'], 'This journal is not in strict mode.') + + # No records to be hashed even if the restrict mode is activated because the hashing is not retroactive + self.company_data['default_journal_sale'].restrict_mode_hash_table = True + integrity_check = moves.company_id._check_hash_integrity()['results'][0] + self.assertEqual(integrity_check['msg_cover'], 'There isn\'t any journal entry flagged for data inalterability yet for this journal.') + + # Everything should be correctly hashed and verified + new_moves = ( + self.init_invoice("out_invoice", self.partner_a, "2023-01-03", amounts=[1000, 2000]) + | self.init_invoice("out_invoice", self.partner_b, "2023-01-04", amounts=[1000, 2000]) + | self.init_invoice("out_invoice", self.partner_a, "2023-01-05", amounts=[1000, 2000]) + | self.init_invoice("out_invoice", self.partner_b, "2023-01-06", amounts=[1000, 2000]) + | self.init_invoice("out_invoice", self.partner_a, "2023-01-07", amounts=[1000, 2000]) + ) + new_moves.action_post() + moves |= new_moves + integrity_check = moves.company_id._check_hash_integrity()['results'][0] + self.assertRegex(integrity_check['msg_cover'], f'Entries are hashed from {moves[2].name}.*') + self.assertEqual(integrity_check['first_move_date'], format_date(self.env, fields.Date.to_string(moves[2].date))) + self.assertEqual(integrity_check['last_move_date'], format_date(self.env, fields.Date.to_string(moves[-1].date))) + + # Let's change one of the fields used by the hash. It should be detected by the integrity report. + # We need to bypass the write method of account.move to do so. + Model.write(moves[4], {'date': fields.Date.from_string('2023-01-07')}) + integrity_check = moves.company_id._check_hash_integrity()['results'][0] + self.assertEqual(integrity_check['msg_cover'], f'Corrupted data on journal entry with id {moves[4].id}.') + + # Revert the previous change + Model.write(moves[4], {'date': fields.Date.from_string("2023-01-05")}) + integrity_check = moves.company_id._check_hash_integrity()['results'][0] + self.assertRegex(integrity_check['msg_cover'], f'Entries are hashed from {moves[2].name}.*') + + # Let's try with the one of the subfields + Model.write(moves[-1].line_ids[0], {'partner_id': self.partner_b.id}) + integrity_check = moves.company_id._check_hash_integrity()['results'][0] + self.assertEqual(integrity_check['msg_cover'], f'Corrupted data on journal entry with id {moves[-1].id}.') + + # Let's try with the inalterable_hash field itself + Model.write(moves[-1].line_ids[0], {'partner_id': self.partner_a.id}) # Revert the previous change + Model.write(moves[-1], {'inalterable_hash': 'fake_hash'}) + integrity_check = moves.company_id._check_hash_integrity()['results'][0] + self.assertEqual(integrity_check['msg_cover'], f'Corrupted data on journal entry with id {moves[-1].id}.') + + def test_account_move_hash_versioning_1(self): + """We are updating the hash algorithm. We want to make sure that we do not break the integrity report. + This test focuses on the case where the user has only moves with the old hash algorithm.""" + self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000, 2000], post=True) # Not hashed + self.company_data['default_journal_sale'].restrict_mode_hash_table = True + moves = ( + self.init_invoice("out_invoice", self.partner_a, "2023-01-02", amounts=[1000, 2000]) + | self.init_invoice("out_invoice", self.partner_b, "2023-01-03", amounts=[1000, 2000]) + | self.init_invoice("out_invoice", self.partner_b, "2023-01-04", amounts=[1000, 2000]) + ) + moves.with_context(hash_version=1).action_post() + integrity_check = moves.company_id._check_hash_integrity()['results'][0] + self.assertRegex(integrity_check['msg_cover'], f'Entries are hashed from {moves[0].name}.*') + self.assertEqual(integrity_check['first_move_date'], format_date(self.env, fields.Date.to_string(moves[0].date))) + self.assertEqual(integrity_check['last_move_date'], format_date(self.env, fields.Date.to_string(moves[-1].date))) + + # Let's change one of the fields used by the hash. It should be detected by the integrity report + # independently of the hash version used. I.e. we first try the v1 hash, then the v2 hash and neither should work. + # We need to bypass the write method of account.move to do so. + Model.write(moves[1], {'date': fields.Date.from_string('2023-01-07')}) + integrity_check = moves.company_id._check_hash_integrity()['results'][0] + self.assertEqual(integrity_check['msg_cover'], f'Corrupted data on journal entry with id {moves[1].id}.') + + def test_account_move_hash_versioning_2(self): + """We are updating the hash algorithm. We want to make sure that we do not break the integrity report. + This test focuses on the case where the user has only moves with the new hash algorithm.""" + self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000, 2000], post=True) # Not hashed + self.company_data['default_journal_sale'].restrict_mode_hash_table = True + moves = ( + self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000, 2000]) + | self.init_invoice("out_invoice", self.partner_b, "2023-01-02", amounts=[1000, 2000]) + | self.init_invoice("out_invoice", self.partner_b, "2023-01-03", amounts=[1000, 2000]) + ) + moves.action_post() + integrity_check = moves.company_id._check_hash_integrity()['results'][0] + self.assertRegex(integrity_check['msg_cover'], f'Entries are hashed from {moves[0].name}.*') + self.assertEqual(integrity_check['first_move_date'], format_date(self.env, fields.Date.to_string(moves[0].date))) + self.assertEqual(integrity_check['last_move_date'], format_date(self.env, fields.Date.to_string(moves[-1].date))) + + # Let's change one of the fields used by the hash. It should be detected by the integrity report + # independently of the hash version used. I.e. we first try the v1 hash, then the v2 hash and neither should work. + # We need to bypass the write method of account.move to do so. + Model.write(moves[1], {'date': fields.Date.from_string('2023-01-07')}) + integrity_check = moves.company_id._check_hash_integrity()['results'][0] + self.assertEqual(integrity_check['msg_cover'], f'Corrupted data on journal entry with id {moves[1].id}.') + + def test_account_move_hash_versioning_v1_to_v2(self): + """We are updating the hash algorithm. We want to make sure that we do not break the integrity report. + This test focuses on the case where the user has moves with both hash algorithms.""" + self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000, 2000], post=True) # Not hashed + self.company_data['default_journal_sale'].restrict_mode_hash_table = True + moves_v1 = ( + self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000, 2000]) + | self.init_invoice("out_invoice", self.partner_b, "2023-01-02", amounts=[1000, 2000]) + | self.init_invoice("out_invoice", self.partner_b, "2023-01-03", amounts=[1000, 2000]) + ) + moves_v1.with_context(hash_version=1).action_post() + fields_v1 = moves_v1.with_context(hash_version=1)._get_integrity_hash_fields() + moves_v2 = ( + self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000, 2000]) + | self.init_invoice("out_invoice", self.partner_b, "2023-01-02", amounts=[1000, 2000]) + | self.init_invoice("out_invoice", self.partner_b, "2023-01-03", amounts=[1000, 2000]) + ) + moves_v2.with_context(hash_version=2).action_post() + fields_v2 = moves_v2._get_integrity_hash_fields() + self.assertNotEqual(fields_v1, fields_v2) # Make sure two different hash algorithms were used + + moves = moves_v1 | moves_v2 + integrity_check = moves.company_id._check_hash_integrity()['results'][0] + self.assertRegex(integrity_check['msg_cover'], f'Entries are hashed from {moves[0].name}.*') + self.assertEqual(integrity_check['first_move_date'], format_date(self.env, fields.Date.to_string(moves[0].date))) + self.assertEqual(integrity_check['last_move_date'], format_date(self.env, fields.Date.to_string(moves[-1].date))) + + # Let's change one of the fields used by the hash. It should be detected by the integrity report + # independently of the hash version used. I.e. we first try the v1 hash, then the v2 hash and neither should work. + # We need to bypass the write method of account.move to do so. + Model.write(moves[4], {'date': fields.Date.from_string('2023-01-07')}) + integrity_check = moves.company_id._check_hash_integrity()['results'][0] + self.assertEqual(integrity_check['msg_cover'], f'Corrupted data on journal entry with id {moves[4].id}.') + + # Let's revert the change and make sure that we cannot use the v1 after the v2. + # This means we don't simply check whether the move is correctly hashed with either algorithms, + # but that we can only use v2 after v1 and not go back to v1 afterwards. + Model.write(moves[4], {'date': fields.Date.from_string("2023-01-02")}) # Revert the previous change + moves_v1_bis = ( + self.init_invoice("out_invoice", self.partner_a, "2023-01-10", amounts=[1000, 2000]) + | self.init_invoice("out_invoice", self.partner_b, "2023-01-11", amounts=[1000, 2000]) + | self.init_invoice("out_invoice", self.partner_b, "2023-01-12", amounts=[1000, 2000]) + ) + moves_v1_bis.with_context(hash_version=1).action_post() + integrity_check = moves.company_id._check_hash_integrity()['results'][0] + self.assertEqual(integrity_check['msg_cover'], f'Corrupted data on journal entry with id {moves_v1_bis[0].id}.')