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 @@ + + + + + + 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 @@