diff --git a/addons/account/__manifest__.py b/addons/account/__manifest__.py
index 1670fd3bc62..f7544acca7b 100644
--- a/addons/account/__manifest__.py
+++ b/addons/account/__manifest__.py
@@ -61,7 +61,8 @@ You could use this simplified accounting in case you work with an (external) acc
'views/digest_views.xml',
'wizard/account_invoice_send_views.xml',
'views/account_tax_report_views.xml',
- 'views/report_statement.xml'
+ 'views/report_statement.xml',
+ 'report/account_hash_integrity_templates.xml'
],
'demo': [
'demo/account_demo.xml',
diff --git a/addons/account/models/account.py b/addons/account/models/account.py
index fb8c6d9cb2c..b3d45dc26f6 100644
--- a/addons/account/models/account.py
+++ b/addons/account/models/account.py
@@ -638,8 +638,8 @@ class AccountJournal(models.Model):
ondelete='restrict')
default_debit_account_id = fields.Many2one('account.account', string='Default Debit Account',
domain="[('deprecated', '=', False), ('company_id', '=', company_id)]", help="It acts as a default account for debit amount", ondelete='restrict')
- update_posted = fields.Boolean(string='Allow Cancelling Entries',
- help="Check this box if you want to allow the cancellation the entries related to this journal or of the invoice related to this journal")
+ restrict_mode_hash_table = fields.Boolean(string="Lock Posted Entries with Hash",
+ help="If ticked, the accounting entry or invoice receives a hash as soon as it is posted and cannot be modified anymore.")
sequence_id = fields.Many2one('ir.sequence', string='Entry Sequence',
help="This field contains the information related to the numbering of the journal entries of this journal.", required=True, copy=False)
refund_sequence_id = fields.Many2one('ir.sequence', string='Credit Note Entry Sequence',
@@ -694,6 +694,8 @@ class AccountJournal(models.Model):
journal_group_ids = fields.Many2many('account.journal.group', domain="[('company_id', '=', company_id)]", string="Journal Groups")
+ secure_sequence_id = fields.Many2one('ir.sequence', help='Sequence to use to ensure the securisation of data', readonly=True, copy=False)
+
_sql_constraints = [
('code_company_uniq', 'unique (code, name, company_id)', 'The code and name of the journal must be unique per company !'),
]
@@ -860,6 +862,11 @@ class AccountJournal(models.Model):
raise UserError(_("The partners of the journal's company and the related bank account mismatch."))
if 'alias_name' in vals:
journal._update_mail_alias(vals)
+ if 'restrict_mode_hash_table' in vals and not vals.get('restrict_mode_hash_table'):
+ journal_entry = self.env['account.move'].search([('journal_id', '=', self.id), ('state', '=', 'posted'), ('secure_sequence_number', '!=', 0)], limit=1)
+ if len(journal_entry) > 0:
+ field_string = self._fields['restrict_mode_hash_table'].get_description(self.env)['string']
+ raise UserError(_("You cannot modify the field %s of a journal that already has accounting entries.") % field_string)
result = super(AccountJournal, self).write(vals)
# Create the bank_account_id if necessary
@@ -882,6 +889,10 @@ class AccountJournal(models.Model):
pending_payments = draft_moves.mapped('line_ids.payment_id')
pending_payments.mapped('move_line_ids.move_id').post()
pending_payments.mapped('reconciled_invoice_ids').filtered(lambda x: x.state == 'in_payment').write({'state': 'paid'})
+ for record in self:
+ if record.restrict_mode_hash_table and not record.secure_sequence_id:
+ record._create_secure_sequence(['secure_sequence_id'])
+
return result
@api.model
@@ -1073,6 +1084,28 @@ class AccountJournal(models.Model):
action_vals['view_mode'] = 'tree,form'
return action_vals
+ def _create_secure_sequence(self, sequence_fields):
+ """This function creates a no_gap sequence on each journal in self that will ensure
+ a unique number is given to all posted account.move in such a way that we can always
+ find the previous move of a journal entry on a specific journal.
+ """
+ for journal in self:
+ vals_write = {}
+ for seq_field in sequence_fields:
+ if not journal[seq_field]:
+ vals = {
+ 'name': _('Securisation of %s - %s') % (seq_field, journal.name),
+ 'code': 'SECUR%s-%s' % (journal.id, seq_field),
+ 'implementation': 'no_gap',
+ 'prefix': '',
+ 'suffix': '',
+ 'padding': 0,
+ 'company_id': journal.company_id.id}
+ seq = self.env['ir.sequence'].create(vals)
+ vals_write[seq_field] = seq.id
+ if vals_write:
+ journal.write(vals_write)
+
class ResPartnerBank(models.Model):
_inherit = "res.partner.bank"
diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py
index 8ddf1b6000a..7799968263d 100644
--- a/addons/account/models/account_bank_statement.py
+++ b/addons/account/models/account_bank_statement.py
@@ -332,6 +332,9 @@ class AccountBankStatement(models.Model):
statement.name = st_number
statement.state = 'open'
+ def button_reopen(self):
+ self.state = 'open'
+
def action_bank_reconcile_bank_statements(self):
self.ensure_one()
bank_stmt_lines = self.mapped('line_ids')
@@ -445,9 +448,9 @@ class AccountBankStatementLine(models.Model):
if aml_to_cancel:
aml_to_cancel.remove_move_reconcile()
moves_to_cancel = aml_to_cancel.mapped('move_id')
- moves_to_cancel.button_cancel()
moves_to_cancel.button_draft()
- moves_to_cancel.unlink()
+ moves_to_cancel.button_cancel()
+ moves_to_cancel.with_context(force_delete=True).unlink()
if payment_to_cancel:
payment_to_cancel.unlink()
diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py
index 131ca0f3a53..2bfe746fbce 100644
--- a/addons/account/models/account_move.py
+++ b/addons/account/models/account_move.py
@@ -10,6 +10,8 @@ from datetime import date
from itertools import groupby, chain
from stdnum.iso7064 import mod_97_10
from itertools import zip_longest
+from hashlib import sha256
+from json import dumps
import json
import re
@@ -18,6 +20,9 @@ import psycopg2
_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']
class AccountMove(models.Model):
_name = "account.move"
@@ -168,8 +173,7 @@ class AccountMove(models.Model):
domain="[('company_id', '=', company_id)]",
help="Fiscal positions are used to adapt taxes and accounts for particular customers or sales orders/invoices. "
"The default value comes from the customer.")
- invoice_user_id = fields.Many2one('res.users', readonly=True, copy=False, tracking=True,
- states={'draft': [('readonly', False)]},
+ invoice_user_id = fields.Many2one('res.users', copy=False, tracking=True,
string='Salesperson',
default=lambda self: self.env.user)
user_id = fields.Many2one(string='User', related='invoice_user_id',
@@ -184,8 +188,7 @@ class AccountMove(models.Model):
states={'draft': [('readonly', False)]})
invoice_date_due = fields.Date(string='Due Date', readonly=True, index=True, copy=False,
states={'draft': [('readonly', False)]})
- invoice_payment_ref = fields.Char(string='Payment Reference', index=True, copy=False, readonly=True,
- states={'draft': [('readonly', False)]},
+ invoice_payment_ref = fields.Char(string='Payment Reference', index=True, copy=False,
help="The payment reference to set on journal items.")
invoice_sent = fields.Boolean(readonly=True, default=False, copy=False,
help="It indicates that the invoice has been sent.")
@@ -201,7 +204,7 @@ class AccountMove(models.Model):
states={'draft': [('readonly', False)]})
invoice_partner_bank_id = fields.Many2one('res.partner.bank', string='Bank Account',
help='Bank Account Number to which the invoice will be paid. A Company bank account if this is a Customer Invoice or Vendor Credit Note, otherwise a Partner bank account number.',
- readonly=True, states={'draft': [('readonly', False)]}, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
+ domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
invoice_incoterm_id = fields.Many2one('account.incoterms', string='Incoterm',
default=_get_default_invoice_incoterm,
help='International Commercial Terms are a series of predefined commercial terms used in international transactions.')
@@ -244,6 +247,11 @@ class AccountMove(models.Model):
help="Technical field used to display an alert on invoices if there is at least a matching amount in any supsense account.")
# Technical field to hide Reconciled Entries stat button
has_reconciled_entries = fields.Boolean(compute="_compute_has_reconciled_entries")
+ # ==== Hash Fields ====
+ restrict_mode_hash_table = fields.Boolean(related='journal_id.restrict_mode_hash_table')
+ secure_sequence_number = fields.Integer(string="Inalteralbility No Gap Sequence #", readonly=True, copy=False)
+ inalterable_hash = fields.Char(string="Inalterability Hash", readonly=True, copy=False)
+ string_to_hash = fields.Char(compute='_compute_string_to_hash', readonly=True)
# -------------------------------------------------------------------------
# ONCHANGE METHODS
@@ -1502,12 +1510,29 @@ class AccountMove(models.Model):
def write(self, vals):
not_paid_invoices = self.filtered(lambda move: move.is_invoice(include_receipts=True) and move.invoice_payment_state not in ('paid', 'in_payment'))
+ 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.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.name != '/' and 'journal_id' in vals and move.journal_id.id != vals['journal_id']):
+ raise UserError(_('You cannot edit the journal of an account move if it has been posted once.'))
+ if 'date' in vals and move.date != vals['date']:
+ move._check_move_consistency(check_balanced=False)
+
if self._move_autocomplete_invoice_lines_write(vals):
res = True
else:
vals.pop('invoice_line_ids', None)
res = super(AccountMove, self.with_context(check_move_validity=False)).write(vals)
+ if ('state' in vals and vals.get('state') == 'posted') and self.restrict_mode_hash_table:
+ for move in self.filtered(lambda m: not(m.secure_sequence_number or m.inalterable_hash)):
+ new_number = move.journal_id.secure_sequence_id.next_by_id()
+ vals_hashing = {'secure_sequence_number': new_number,
+ 'inalterable_hash': move._get_new_hash(new_number)}
+ res |= super(AccountMove, move).write(vals_hashing)
+
# Ensure the move is still well balanced.
if 'line_ids' in vals and self._context.get('check_move_validity', True):
# 'check_move_validity' is needed as the write will be done line per line.
@@ -1520,8 +1545,8 @@ class AccountMove(models.Model):
def unlink(self):
for move in self:
- # check the lock date + check if some entries are reconciled
- move.line_ids._update_check()
+ if move.name != '/' and not self._context.get('force_delete'):
+ raise UserError(_("You cannot delete an entry which has been posted once."))
move.line_ids.unlink()
return super(AccountMove, self).unlink()
@@ -2000,11 +2025,6 @@ class AccountMove(models.Model):
return lines.reconcile()
def button_draft(self):
- if any(move.state != 'cancel' for move in self):
- raise UserError(_('Only cancelled journal entries can be reset to draft.'))
- self.write({'state': 'draft'})
-
- def button_cancel(self):
self._check_tax_lock_date()
AccountMoveLine = self.env['account.move.line']
excluded_move_ids = []
@@ -2014,21 +2034,20 @@ class AccountMove(models.Model):
for move in self:
if move in move.line_ids.mapped('full_reconcile_id.exchange_move_id'):
- raise UserError(_('You cannot cancel an exchange difference journal entry.'))
+ raise UserError(_('You cannot reset to draft an exchange difference journal entry.'))
if move.tax_cash_basis_rec_id:
- raise UserError(_('You cannot cancel a tax cash basis journal entry.'))
- if not move.journal_id.update_posted and move.id not in excluded_move_ids:
- raise UserError(_('You cannot modify a posted entry of this journal.\nFirst you should set the journal to allow cancelling entries.'))
+ raise UserError(_('You cannot reset to draft a tax cash basis journal entry.'))
+ if move.restrict_mode_hash_table and move.state == 'posted' and move.id not in excluded_move_ids:
+ raise UserError(_('You cannot modify a posted entry of this journal because it is in strict mode.'))
# We remove all the analytics entries for this journal
move.mapped('line_ids.analytic_line_ids').unlink()
- if self.ids:
- self.check_access_rights('write')
- self.check_access_rule('write')
- self._check_fiscalyear_lock_date()
- self._cr.execute('UPDATE account_move SET state=%s WHERE id IN %s', ('cancel', tuple(self.ids)))
- self.invalidate_cache()
+
self._check_fiscalyear_lock_date()
self.mapped('line_ids').remove_move_reconcile()
+ self.write({'state': 'draft'})
+
+ def button_cancel(self):
+ self.write({'state': 'cancel'})
return True
def action_invoice_sent(self):
@@ -2060,6 +2079,51 @@ class AccountMove(models.Model):
'context': ctx,
}
+ def _get_new_hash(self, secure_seq_number):
+ """ Returns the hash to write on journal entries when they get posted"""
+ self.ensure_one()
+ #get the only one exact previous move in the securisation sequence
+ prev_move = self.search([('state', '=', 'posted'),
+ ('company_id', '=', self.company_id.id),
+ ('journal_id', '=', self.journal_id.id),
+ ('secure_sequence_number', '!=', 0),
+ ('secure_sequence_number', '=', int(secure_seq_number) - 1)])
+ if prev_move and len(prev_move) != 1:
+ raise UserError(
+ _('An error occured when computing the inalterability. Impossible to get the unique previous posted journal entry.'))
+
+ #build and return the hash
+ return self._compute_hash(prev_move.inalterable_hash if prev_move else u'')
+
+ def _compute_hash(self, previous_hash):
+ """ Computes the hash of the browse_record given as self, based on the hash
+ of the previous record in the company's securisation sequence given as parameter"""
+ self.ensure_one()
+ hash_string = sha256((previous_hash + self.string_to_hash).encode('utf-8'))
+ return hash_string.hexdigest()
+
+ def _compute_string_to_hash(self):
+ def _getattrstring(obj, field_str):
+ field_value = obj[field_str]
+ if obj._fields[field_str].type == 'many2one':
+ field_value = field_value.id
+ return str(field_value)
+
+ for move in self:
+ values = {}
+ for field in INTEGRITY_HASH_MOVE_FIELDS:
+ values[field] = _getattrstring(move, field)
+
+ for line in move.line_ids:
+ for field in INTEGRITY_HASH_LINE_FIELDS:
+ k = 'line_%d_%s' % (line.id, field)
+ values[k] = _getattrstring(line, field)
+ #make the json serialization canonical
+ # (https://tools.ietf.org/html/draft-staykov-hu-json-canonical-form-00)
+ move.string_to_hash = dumps(values, sort_keys=True,
+ ensure_ascii=True, indent=None,
+ separators=(',',':'))
+
def action_invoice_print(self):
""" Print the invoice and mark it as sent, so that we can see more
easily the next step of the workflow
@@ -2282,11 +2346,11 @@ class AccountMoveLine(models.Model):
amount_residual_currency = fields.Monetary(string='Residual Amount in Currency', store=True,
compute='_amount_residual',
help="The residual amount on a journal item expressed in its currency (possibly not the company currency).")
- full_reconcile_id = fields.Many2one('account.full.reconcile', string="Matching #", copy=False, index=True)
+ full_reconcile_id = fields.Many2one('account.full.reconcile', string="Matching #", copy=False, index=True, readonly=True)
matched_debit_ids = fields.One2many('account.partial.reconcile', 'credit_move_id', string='Matched Debits',
- help='Debit journal items that are matched with this journal item.')
+ help='Debit journal items that are matched with this journal item.', readonly=True)
matched_credit_ids = fields.One2many('account.partial.reconcile', 'debit_move_id', string='Matched Credits',
- help='Credit journal items that are matched with this journal item.')
+ help='Credit journal items that are matched with this journal item.', readonly=True)
# ==== Analytic fields ====
analytic_line_ids = fields.One2many('account.analytic.line', 'move_id', string='Analytic lines')
@@ -2847,8 +2911,6 @@ class AccountMoveLine(models.Model):
move_ids = set()
for line in self:
err_msg = _('Move name (id): %s (%s)') % (line.move_id.name, str(line.move_id.id))
- if line.move_id.state == 'posted':
- raise UserError(_('You cannot do this modification on a posted journal entry, you can just change some non legal fields. You must revert the journal entry to cancel it.\n%s.') % err_msg)
if line.reconciled and not (line.debit == 0 and line.credit == 0):
raise UserError(_('You cannot do this modification on a reconciled entry. You can just change some non legal fields or you must unreconcile first.\n%s.') % err_msg)
if line.move_id.id not in move_ids:
@@ -2970,8 +3032,13 @@ class AccountMoveLine(models.Model):
# this is needed to compute the correct amount_residual_currency and potentially create an exchange difference entry
self._update_check()
# when making a reconciliation on an existing liquidity journal item, mark the payment as reconciled
- for record in self:
- if 'statement_line_id' in vals and record.payment_id:
+ for line in self:
+ 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_ids')):
+ raise UserError(_('You cannot modify the taxes related to a posted journal item, you should reset the journal entry to draft to do so.'))
+ if 'statement_line_id' in vals and line.payment_id:
# In case of an internal transfer, there are 2 liquidity move lines to match with a bank statement
if all(line.statement_id for line in record.payment_id.move_line_ids.filtered(
lambda r: r.id != record.id and r.account_id.internal_type == 'liquidity')):
diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py
index 6b4dd3a1078..f15e1539ed1 100644
--- a/addons/account/models/account_payment.py
+++ b/addons/account/models/account_payment.py
@@ -441,16 +441,7 @@ class account_payment(models.Model):
payment.write({'state': 'posted'})
def cancel(self):
- for rec in self:
- for move in rec.move_line_ids.mapped('move_id'):
- if rec.reconciled_invoice_ids:
- move.line_ids.remove_move_reconcile()
- if move.state != 'draft':
- move.button_cancel()
- move.unlink()
- rec.write({
- 'state': 'cancelled',
- })
+ self.write({'state': 'cancelled'})
def unlink(self):
if any(bool(rec.move_line_ids) for rec in self):
@@ -703,7 +694,10 @@ class account_payment(models.Model):
return True
def action_draft(self):
- return self.write({'state': 'draft'})
+ moves = self.mapped('move_line_ids.move_id')
+ moves.filtered(lambda move: move.state == 'posted').button_draft()
+ moves.with_context(force_delete=True).unlink()
+ self.write({'state': 'draft'})
def _get_invoice_payment_amount(self, inv):
"""
diff --git a/addons/account/models/company.py b/addons/account/models/company.py
index e40265d4f64..7116dafff8c 100644
--- a/addons/account/models/company.py
+++ b/addons/account/models/company.py
@@ -6,7 +6,7 @@ from dateutil.relativedelta import relativedelta
from odoo import fields, models, api, _
from odoo.exceptions import ValidationError, UserError, RedirectWarning
-from odoo.tools.misc import DEFAULT_SERVER_DATE_FORMAT
+from odoo.tools.misc import DEFAULT_SERVER_DATE_FORMAT, format_date
from odoo.tools.float_utils import float_round, float_is_zero
from odoo.tools import date_utils
from odoo.tests.common import Form
@@ -553,3 +553,82 @@ class ResCompany(models.Model):
"Please go to Account Configuration and select or install a fiscal localization.")
raise RedirectWarning(msg, action.id, _("Go to the configuration panel"))
return account
+
+ @api.model
+ def _action_check_hash_integrity(self):
+ return self.env.ref('account.action_report_account_hash_integrity').report_action(self.id)
+
+ def _check_hash_integrity(self):
+ """Checks that all posted moves have still the same data as when they were posted
+ and raises an error with the result.
+ """
+ def build_move_info(move):
+ return(move.name, move.inalterable_hash, fields.Date.to_string(move.date))
+
+ journals = self.env['account.journal'].search([('company_id', '=', self.id)])
+ results_by_journal = {
+ 'results': [],
+ 'printing_date': format_date(self.env, fields.Date.to_string(fields.Date.today()))
+ }
+
+ for journal in journals:
+ rslt = {
+ 'journal_name': journal.name,
+ 'journal_code': journal.code,
+ 'restricted_by_hash_table': journal.restrict_mode_hash_table and 'V' or 'X',
+ 'msg_cover': '',
+ 'first_hash': 'None',
+ 'first_move_name': 'None',
+ 'first_move_date': 'None',
+ 'last_hash': 'None',
+ 'last_move_name': 'None',
+ 'last_move_date': 'None',
+ }
+ if not journal.restrict_mode_hash_table:
+ rslt.update({'msg_cover': _('This journal is not in strict mode.')})
+ results_by_journal['results'].append(rslt)
+ continue
+
+ all_moves_count = self.env['account.move'].search_count([('state', '=', 'posted'), ('journal_id', '=', journal.id)])
+ moves = self.env['account.move'].search([('state', '=', 'posted'), ('journal_id', '=', journal.id),
+ ('secure_sequence_number', '!=', 0)], order="secure_sequence_number ASC")
+ if not moves:
+ rslt.update({
+ 'msg_cover': _('There isn\'t any journal entry flagged for data inalterability yet for this journal.'),
+ })
+ results_by_journal['results'].append(rslt)
+ continue
+
+ previous_hash = u''
+ start_move_info = []
+ hash_corrupted = False
+ for move in moves:
+ if move.inalterable_hash != move._compute_hash(previous_hash=previous_hash):
+ rslt.update({'msg_cover': _('Corrupted data on journal entry with id %s.') % move.id})
+ results_by_journal['results'].append(rslt)
+ hash_corrupted = True
+ break
+ if not previous_hash:
+ #save the date and sequence number of the first move hashed
+ start_move_info = build_move_info(move)
+ previous_hash = move.inalterable_hash
+ end_move_info = build_move_info(move)
+
+ if hash_corrupted:
+ continue
+
+ rslt.update({
+ 'first_move_name': start_move_info[0],
+ 'first_hash': start_move_info[1],
+ 'first_move_date': format_date(self.env, start_move_info[2]),
+ 'last_move_name': end_move_info[0],
+ 'last_hash': end_move_info[1],
+ 'last_move_date': format_date(self.env, end_move_info[2]),
+ })
+ if len(moves) == all_moves_count:
+ rslt.update({'msg_cover': _('All entries are hashed.')})
+ else:
+ rslt.update({'msg_cover': _('Entries are hashed from %s (%s)') % (start_move_info[0], format_date(self.env, start_move_info[2]))})
+ results_by_journal['results'].append(rslt)
+
+ return results_by_journal
diff --git a/addons/account/report/__init__.py b/addons/account/report/__init__.py
index 0082db0381a..085f3cea256 100644
--- a/addons/account/report/__init__.py
+++ b/addons/account/report/__init__.py
@@ -4,3 +4,4 @@
from . import account_invoice_report
from . import account_journal
from . import account_aged_partner_balance
+from . import account_hash_integrity_templates
diff --git a/addons/account/report/account_hash_integrity_templates.py b/addons/account/report/account_hash_integrity_templates.py
new file mode 100644
index 00000000000..fe246841522
--- /dev/null
+++ b/addons/account/report/account_hash_integrity_templates.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class ReportAccountHashIntegrity(models.AbstractModel):
+ _name = 'report.account.report_hash_integrity'
+ _description = 'Get hash integrity result as PDF.'
+
+ @api.model
+ def _get_report_values(self, docids, data=None):
+ if data:
+ data.update(self.env.company._check_hash_integrity())
+ else:
+ data = self.env.company._check_hash_integrity()
+ return {
+ 'doc_ids' : docids,
+ 'doc_model' : self.env['res.company'],
+ 'data' : data,
+ 'docs' : self.env['res.company'].browse(self.env.company.id),
+ }
diff --git a/addons/account/report/account_hash_integrity_templates.xml b/addons/account/report/account_hash_integrity_templates.xml
new file mode 100644
index 00000000000..14716749395
--- /dev/null
+++ b/addons/account/report/account_hash_integrity_templates.xml
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
Hash Integrity Result -
+
+
+
+
+
+
+
Configuration review
+
+
+
+
+
+
+
+
+
Journal
+
Inalterability check
+
Coverage
+
+
+
+
+
+
+ []
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Data consistency check
+
+
+
+
+
+
+
+
+
Journal
+
First Hash
+
First Entry
+
Last Hash
+
Last Entry
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The hash chain is compliant: it is not possible to alter the
+ data without breaking the hash chain for subsequent parts.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/account/tests/test_account_move_partner_count.py b/addons/account/tests/test_account_move_partner_count.py
index 68527cf7572..203c4dc0493 100644
--- a/addons/account/tests/test_account_move_partner_count.py
+++ b/addons/account/tests/test_account_move_partner_count.py
@@ -45,6 +45,7 @@ class TestAccountMovePartnerCount(TransactionCase):
with self.registry.cursor() as cr:
env = self.env(cr=cr)
move = self.move.with_env(env)
+ move.name = '/'
move.state = 'draft'
move.unlink()
self.journal.with_env(env).unlink()
diff --git a/addons/account/tests/test_account_move_tax_lock_date.py b/addons/account/tests/test_account_move_tax_lock_date.py
index 280536e6b46..efd383cb815 100644
--- a/addons/account/tests/test_account_move_tax_lock_date.py
+++ b/addons/account/tests/test_account_move_tax_lock_date.py
@@ -19,12 +19,11 @@ class TestTaxBlockDate(AccountingTestCase):
self.company = self.env.company
company_id = self.company.id
- last_day_month = date.today()
- self.last_day_month = last_day_month.replace(day=monthrange(last_day_month.year, last_day_month.month)[1])
- first_day_month = date.today()
- self.first_day_month = first_day_month.replace(day=1)
- middle_day_month = date.today()
- middle_day_month = middle_day_month.replace(day=15)
+ last_day_previous_month = date.today()
+ last_day_previous_month = last_day_previous_month.replace(month=last_day_previous_month.month-1)
+ self.last_day_previous_month = last_day_previous_month.replace(day=monthrange(last_day_previous_month.year, last_day_previous_month.month)[1])
+ self.first_day_previous_month = self.last_day_previous_month.replace(day=1)
+ middle_day_previous_month = self.last_day_previous_month.replace(day=15)
self.sale_journal_id = self.env['account.journal'].search([('type', '=', 'sale'), ('company_id', '=', company_id)], limit=1)[0]
self.account_id = self.env['account.account'].search([('internal_type', '=', 'receivable'), ('company_id', '=', company_id)], limit=1)[0]
@@ -34,7 +33,7 @@ class TestTaxBlockDate(AccountingTestCase):
self.move = {
'name': '/',
'journal_id': self.sale_journal_id.id,
- 'date': middle_day_month,
+ 'date': middle_day_previous_month,
'line_ids': [(0, 0, {
'name': 'foo',
'debit': 10,
@@ -50,7 +49,7 @@ class TestTaxBlockDate(AccountingTestCase):
self.move_no_tax = {
'name': '/',
'journal_id': self.sale_journal_id.id,
- 'date': middle_day_month,
+ 'date': middle_day_previous_month,
'line_ids': [(0, 0, {
'name': 'foo',
'debit': 10,
@@ -67,32 +66,31 @@ class TestTaxBlockDate(AccountingTestCase):
before the tax lock date.
"""
# Posting after the lock date is always allowed
- self.company.tax_lock_date = self.first_day_month
+ self.company.tax_lock_date = self.first_day_previous_month
# copy() because mail.thread modifies the dictionary given to create
move_after_lock = self.env['account.move'].create(self.move.copy())
move_after_lock.post()
# Posting before the tax lock date is allowed only if the move doesn't contain any tax
- self.user_id.company_id.tax_lock_date = self.last_day_month
+ self.user_id.company_id.tax_lock_date = self.last_day_previous_month
move_before_lock_no_tax = self.env['account.move'].create(self.move_no_tax)
move_before_lock_no_tax.post()
# Posting before the tax lock date is not allowed for moves impacting the tax report
- self.user_id.company_id.tax_lock_date = self.last_day_month
+ self.user_id.company_id.tax_lock_date = self.last_day_previous_month
with self.assertRaises(UserError):
self.env['account.move'].create(self.move.copy())
- def test_tax_lock_date_cancel(self):
- """ Checks that it is not possible to cancel an entry posted before the
+ def test_tax_lock_date_reset_to_draft(self):
+ """ Checks that it is not possible to reset to draft an entry posted before the
tax lock date if it impacts the tax report. """
- self.sale_journal_id.update_posted = True
move = self.env['account.move'].create(self.move)
move.post()
move_no_tax = self.env['account.move'].create(self.move_no_tax)
move_no_tax.post()
- self.user_id.company_id.tax_lock_date = self.last_day_month
+ self.user_id.company_id.tax_lock_date = self.last_day_previous_month
- move_no_tax.button_cancel()
+ move_no_tax.button_draft()
with self.assertRaises(UserError):
- move.button_cancel()
+ move.button_draft()
diff --git a/addons/account/tests/test_payment.py b/addons/account/tests/test_payment.py
index 557a482d34f..953d6c1754c 100644
--- a/addons/account/tests/test_payment.py
+++ b/addons/account/tests/test_payment.py
@@ -428,9 +428,7 @@ class TestPayment(AccountingTestCase):
# The invoice should now be paid
self.assertEqual(invoice.invoice_payment_state, 'paid', "Invoice should be in 'paid' state after having reconciled the two payments with a bank statement")
- def test_payment_cancel_keep_name(self):
- self.bank_journal_euro.update_posted = True
-
+ def test_payment_draft_keep_name(self):
payment = self.payment_model.create({
'payment_type': 'inbound',
'payment_method_id': self.payment_method_manual_in.id,
@@ -447,18 +445,14 @@ class TestPayment(AccountingTestCase):
name = payment.move_line_ids.mapped('move_id').name
self.assertTrue(name)
- payment.cancel()
- self.assertFalse(payment.move_line_ids.mapped('move_id'))
payment.action_draft()
+ self.assertFalse(payment.move_line_ids.mapped('move_id'))
payment.post()
self.assertEqual(len(payment.move_line_ids.mapped('move_id')), 1)
self.assertEqual(name, payment.move_line_ids.mapped('move_id').name)
- def test_payment_transfer_cancel_keep_names(self):
- self.bank_journal_euro.update_posted = True
- self.cash_journal_euro.update_posted = True
-
+ def test_payment_transfer_draft_keep_names(self):
payment = self.payment_model.create({
'payment_type': 'transfer',
'payment_method_id': self.payment_method_manual_out.id,
@@ -487,9 +481,8 @@ class TestPayment(AccountingTestCase):
self.assertEqual(reconciled_lines.mapped('move_id'), all_moves)
reconciled_lines.remove_move_reconcile()
- payment.cancel()
- self.assertFalse(payment.move_line_ids.mapped('move_id'))
payment.action_draft()
+ self.assertFalse(payment.move_line_ids.mapped('move_id'))
payment.post()
self.assertEqual(len(payment.move_line_ids.mapped('move_id')), 2)
@@ -502,9 +495,7 @@ class TestPayment(AccountingTestCase):
self.assertEqual(name, move.name)
self.assertEqual(transfer_name, transfer_move.name)
- def test_payment_cancel_to_transfer(self):
- self.bank_journal_euro.update_posted = True
-
+ def test_payment_draft_to_transfer(self):
payment = self.payment_model.create({
'payment_type': 'inbound',
'payment_method_id': self.payment_method_manual_in.id,
@@ -521,9 +512,8 @@ class TestPayment(AccountingTestCase):
name = payment.move_line_ids.mapped('move_id').name
self.assertTrue(name)
- payment.cancel()
- self.assertFalse(payment.move_line_ids.mapped('move_id'))
payment.action_draft()
+ self.assertFalse(payment.move_line_ids.mapped('move_id'))
payment.write({
'payment_type': 'transfer',
diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py
index 94e5e69bee4..91c52a22c85 100644
--- a/addons/account/tests/test_reconciliation.py
+++ b/addons/account/tests/test_reconciliation.py
@@ -1506,7 +1506,7 @@ class TestReconciliationExec(TestReconciliation):
partner = self.env['res.partner'].create({'name': 'UncertainPartner'})
currency = self.env.company.currency_id
invoice = self.create_invoice_partner(currency_id=currency.id, partner_id=partner.id)
- journal = self.env['account.journal'].create({'name': 'Bank', 'type': 'bank', 'code': 'THE', 'update_posted':True})
+ journal = self.env['account.journal'].create({'name': 'Bank', 'type': 'bank', 'code': 'THE', 'restrict_mode_hash_table':False})
statement = self.make_payment(invoice, journal, 50)
st_line = statement.line_ids
diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml
index 12aace1ff9b..2def26b8cb3 100644
--- a/addons/account/views/account_move_views.xml
+++ b/addons/account/views/account_move_views.xml
@@ -27,7 +27,7 @@
-
+
@@ -68,8 +68,10 @@
-
-
+
+
@@ -138,7 +140,7 @@
-
+
@@ -507,6 +509,10 @@
+
+
@@ -557,6 +563,7 @@
+