summaryrefslogtreecommitdiff
path: root/addons/account/models
diff options
context:
space:
mode:
authorFlorian Gilbert <flg@odoo.com>2019-08-22 10:03:26 +0000
committerFlorian Gilbert <flg@odoo.com>2019-09-25 11:26:02 +0000
commitab483a3a6610a09391ec00e9c1c49489ca4a4f12 (patch)
tree0882cebae27345110d8049970e37eaa3fea11991 /addons/account/models
parent02dfe1e8e789ee783f58f667efdf47c017585298 (diff)
[IMP] account: Add restrict mode and normal mode for account move.
This commit deletes account_cancel module and squash it into account. The 'update_posted' field is changed by 'restrict_mode_hash_table' field. If 'restrict_mode_hash_table' is true, you have a hash chain on your account journal. These hash chains prove the inalterability of your accounting. l10n_fr_certification module is deleted and all hashing method are moved in account module. Now, you can download a PDF report about your inalterability in the company settings. If you have l10n_fr or l10n_post_cert installed you have more information on this report like the inalterability of pos orders, etc. Task ID: 2039160
Diffstat (limited to 'addons/account/models')
-rw-r--r--addons/account/models/account.py37
-rw-r--r--addons/account/models/account_bank_statement.py7
-rw-r--r--addons/account/models/account_move.py125
-rw-r--r--addons/account/models/account_payment.py16
-rw-r--r--addons/account/models/company.py81
5 files changed, 221 insertions, 45 deletions
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