diff options
| author | Florian Gilbert <flg@odoo.com> | 2019-08-22 10:03:26 +0000 |
|---|---|---|
| committer | Florian Gilbert <flg@odoo.com> | 2019-09-25 11:26:02 +0000 |
| commit | ab483a3a6610a09391ec00e9c1c49489ca4a4f12 (patch) | |
| tree | 0882cebae27345110d8049970e37eaa3fea11991 /addons/account/models | |
| parent | 02dfe1e8e789ee783f58f667efdf47c017585298 (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.py | 37 | ||||
| -rw-r--r-- | addons/account/models/account_bank_statement.py | 7 | ||||
| -rw-r--r-- | addons/account/models/account_move.py | 125 | ||||
| -rw-r--r-- | addons/account/models/account_payment.py | 16 | ||||
| -rw-r--r-- | addons/account/models/company.py | 81 |
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 |
