summaryrefslogtreecommitdiff
path: root/addons/account/models/account_bank_statement.py
diff options
context:
space:
mode:
Diffstat (limited to 'addons/account/models/account_bank_statement.py')
-rw-r--r--addons/account/models/account_bank_statement.py1305
1 files changed, 1305 insertions, 0 deletions
diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py
new file mode 100644
index 00000000..203b4b2b
--- /dev/null
+++ b/addons/account/models/account_bank_statement.py
@@ -0,0 +1,1305 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, _
+from odoo.osv import expression
+from odoo.tools import float_is_zero
+from odoo.tools import float_compare, float_round, float_repr
+from odoo.tools.misc import formatLang, format_date
+from odoo.exceptions import UserError, ValidationError
+
+import time
+import math
+import base64
+import re
+
+
+class AccountCashboxLine(models.Model):
+ """ Cash Box Details """
+ _name = 'account.cashbox.line'
+ _description = 'CashBox Line'
+ _rec_name = 'coin_value'
+ _order = 'coin_value'
+
+ @api.depends('coin_value', 'number')
+ def _sub_total(self):
+ """ Calculates Sub total"""
+ for cashbox_line in self:
+ cashbox_line.subtotal = cashbox_line.coin_value * cashbox_line.number
+
+ coin_value = fields.Float(string='Coin/Bill Value', required=True, digits=0)
+ number = fields.Integer(string='#Coins/Bills', help='Opening Unit Numbers')
+ subtotal = fields.Float(compute='_sub_total', string='Subtotal', digits=0, readonly=True)
+ cashbox_id = fields.Many2one('account.bank.statement.cashbox', string="Cashbox")
+ currency_id = fields.Many2one('res.currency', related='cashbox_id.currency_id')
+
+
+class AccountBankStmtCashWizard(models.Model):
+ """
+ Account Bank Statement popup that allows entering cash details.
+ """
+ _name = 'account.bank.statement.cashbox'
+ _description = 'Bank Statement Cashbox'
+ _rec_name = 'id'
+
+ cashbox_lines_ids = fields.One2many('account.cashbox.line', 'cashbox_id', string='Cashbox Lines')
+ start_bank_stmt_ids = fields.One2many('account.bank.statement', 'cashbox_start_id')
+ end_bank_stmt_ids = fields.One2many('account.bank.statement', 'cashbox_end_id')
+ total = fields.Float(compute='_compute_total')
+ currency_id = fields.Many2one('res.currency', compute='_compute_currency')
+
+ @api.depends('start_bank_stmt_ids', 'end_bank_stmt_ids')
+ def _compute_currency(self):
+ for cashbox in self:
+ cashbox.currency_id = False
+ if cashbox.end_bank_stmt_ids:
+ cashbox.currency_id = cashbox.end_bank_stmt_ids[0].currency_id
+ if cashbox.start_bank_stmt_ids:
+ cashbox.currency_id = cashbox.start_bank_stmt_ids[0].currency_id
+
+ @api.depends('cashbox_lines_ids', 'cashbox_lines_ids.coin_value', 'cashbox_lines_ids.number')
+ def _compute_total(self):
+ for cashbox in self:
+ cashbox.total = sum([line.subtotal for line in cashbox.cashbox_lines_ids])
+
+ @api.model
+ def default_get(self, fields):
+ vals = super(AccountBankStmtCashWizard, self).default_get(fields)
+ balance = self.env.context.get('balance')
+ statement_id = self.env.context.get('statement_id')
+ if 'start_bank_stmt_ids' in fields and not vals.get('start_bank_stmt_ids') and statement_id and balance == 'start':
+ vals['start_bank_stmt_ids'] = [(6, 0, [statement_id])]
+ if 'end_bank_stmt_ids' in fields and not vals.get('end_bank_stmt_ids') and statement_id and balance == 'close':
+ vals['end_bank_stmt_ids'] = [(6, 0, [statement_id])]
+
+ return vals
+
+ def name_get(self):
+ result = []
+ for cashbox in self:
+ result.append((cashbox.id, str(cashbox.total)))
+ return result
+
+ @api.model_create_multi
+ def create(self, vals):
+ cashboxes = super(AccountBankStmtCashWizard, self).create(vals)
+ cashboxes._validate_cashbox()
+ return cashboxes
+
+ def write(self, vals):
+ res = super(AccountBankStmtCashWizard, self).write(vals)
+ self._validate_cashbox()
+ return res
+
+ def _validate_cashbox(self):
+ for cashbox in self:
+ if cashbox.start_bank_stmt_ids:
+ cashbox.start_bank_stmt_ids.write({'balance_start': cashbox.total})
+ if cashbox.end_bank_stmt_ids:
+ cashbox.end_bank_stmt_ids.write({'balance_end_real': cashbox.total})
+
+
+class AccountBankStmtCloseCheck(models.TransientModel):
+ """
+ Account Bank Statement wizard that check that closing balance is correct.
+ """
+ _name = 'account.bank.statement.closebalance'
+ _description = 'Bank Statement Closing Balance'
+
+ def validate(self):
+ bnk_stmt_id = self.env.context.get('active_id', False)
+ if bnk_stmt_id:
+ self.env['account.bank.statement'].browse(bnk_stmt_id).button_validate()
+ return {'type': 'ir.actions.act_window_close'}
+
+
+class AccountBankStatement(models.Model):
+ _name = "account.bank.statement"
+ _description = "Bank Statement"
+ _order = "date desc, name desc, id desc"
+ _inherit = ['mail.thread', 'sequence.mixin']
+ _check_company_auto = True
+ _sequence_index = "journal_id"
+
+ # Note: the reason why we did 2 separate function with the same dependencies (one for balance_start and one for balance_end_real)
+ # is because if we create a bank statement with a default value for one of the field but not the other, the compute method
+ # won't be called and therefore the other field will have a value of 0 and we don't want that.
+ @api.depends('previous_statement_id', 'previous_statement_id.balance_end_real')
+ def _compute_starting_balance(self):
+ for statement in self:
+ if statement.previous_statement_id.balance_end_real != statement.balance_start:
+ statement.balance_start = statement.previous_statement_id.balance_end_real
+ else:
+ # Need default value
+ statement.balance_start = statement.balance_start or 0.0
+
+ @api.depends('previous_statement_id', 'previous_statement_id.balance_end_real')
+ def _compute_ending_balance(self):
+ latest_statement = self.env['account.bank.statement'].search([('journal_id', '=', self[0].journal_id.id)], limit=1)
+ for statement in self:
+ # recompute balance_end_real in case we are in a bank journal and if we change the
+ # balance_end_real of previous statement as we don't want
+ # holes in case we add a statement in between 2 others statements.
+ # We only do this for the bank journal as we use the balance_end_real in cash
+ # journal for verification and creating cash difference entries so we don't want
+ # to recompute the value in that case
+ if statement.journal_type == 'bank':
+ # If we are on last statement and that statement already has a balance_end_real, don't change the balance_end_real
+ # Otherwise, recompute balance_end_real to prevent holes between statement.
+ if latest_statement.id and statement.id == latest_statement.id and not float_is_zero(statement.balance_end_real, precision_digits=statement.currency_id.decimal_places):
+ statement.balance_end_real = statement.balance_end_real or 0.0
+ else:
+ total_entry_encoding = sum([line.amount for line in statement.line_ids])
+ statement.balance_end_real = statement.previous_statement_id.balance_end_real + total_entry_encoding
+ else:
+ # Need default value
+ statement.balance_end_real = statement.balance_end_real or 0.0
+
+ @api.depends('line_ids', 'balance_start', 'line_ids.amount', 'balance_end_real')
+ def _end_balance(self):
+ for statement in self:
+ statement.total_entry_encoding = sum([line.amount for line in statement.line_ids])
+ statement.balance_end = statement.balance_start + statement.total_entry_encoding
+ statement.difference = statement.balance_end_real - statement.balance_end
+
+ def _is_difference_zero(self):
+ for bank_stmt in self:
+ bank_stmt.is_difference_zero = float_is_zero(bank_stmt.difference, precision_digits=bank_stmt.currency_id.decimal_places)
+
+ @api.depends('journal_id')
+ def _compute_currency(self):
+ for statement in self:
+ statement.currency_id = statement.journal_id.currency_id or statement.company_id.currency_id
+
+ @api.depends('move_line_ids')
+ def _get_move_line_count(self):
+ for statement in self:
+ statement.move_line_count = len(statement.move_line_ids)
+
+ @api.model
+ def _default_journal(self):
+ journal_type = self.env.context.get('journal_type', False)
+ company_id = self.env.company.id
+ if journal_type:
+ journals = self.env['account.journal'].search([('type', '=', journal_type), ('company_id', '=', company_id)])
+ if journals:
+ return journals[0]
+ return self.env['account.journal']
+
+ @api.depends('balance_start', 'previous_statement_id')
+ def _compute_is_valid_balance_start(self):
+ for bnk in self:
+ bnk.is_valid_balance_start = (
+ bnk.currency_id.is_zero(
+ bnk.balance_start - bnk.previous_statement_id.balance_end_real
+ )
+ if bnk.previous_statement_id
+ else True
+ )
+
+ @api.depends('date', 'journal_id')
+ def _get_previous_statement(self):
+ for st in self:
+ # Search for the previous statement
+ domain = [('date', '<=', st.date), ('journal_id', '=', st.journal_id.id)]
+ # The reason why we have to perform this test is because we have two use case here:
+ # First one is in case we are creating a new record, in that case that new record does
+ # not have any id yet. However if we are updating an existing record, the domain date <= st.date
+ # will find the record itself, so we have to add a condition in the search to ignore self.id
+ if not isinstance(st.id, models.NewId):
+ domain.extend(['|', '&', ('id', '<', st.id), ('date', '=', st.date), '&', ('id', '!=', st.id), ('date', '!=', st.date)])
+ previous_statement = self.search(domain, limit=1)
+ st.previous_statement_id = previous_statement.id
+
+ name = fields.Char(string='Reference', states={'open': [('readonly', False)]}, copy=False, readonly=True)
+ reference = fields.Char(string='External Reference', states={'open': [('readonly', False)]}, copy=False, readonly=True, help="Used to hold the reference of the external mean that created this statement (name of imported file, reference of online synchronization...)")
+ date = fields.Date(required=True, states={'confirm': [('readonly', True)]}, index=True, copy=False, default=fields.Date.context_today)
+ date_done = fields.Datetime(string="Closed On")
+ balance_start = fields.Monetary(string='Starting Balance', states={'confirm': [('readonly', True)]}, compute='_compute_starting_balance', readonly=False, store=True)
+ balance_end_real = fields.Monetary('Ending Balance', states={'confirm': [('readonly', True)]}, compute='_compute_ending_balance', readonly=False, store=True)
+ state = fields.Selection(string='Status', required=True, readonly=True, copy=False, selection=[
+ ('open', 'New'),
+ ('posted', 'Processing'),
+ ('confirm', 'Validated'),
+ ], default='open',
+ help="The current state of your bank statement:"
+ "- New: Fully editable with draft Journal Entries."
+ "- Processing: No longer editable with posted Journal entries, ready for the reconciliation."
+ "- Validated: All lines are reconciled. There is nothing left to process.")
+ currency_id = fields.Many2one('res.currency', compute='_compute_currency', string="Currency")
+ journal_id = fields.Many2one('account.journal', string='Journal', required=True, states={'confirm': [('readonly', True)]}, default=_default_journal, check_company=True)
+ journal_type = fields.Selection(related='journal_id.type', help="Technical field used for usability purposes")
+ company_id = fields.Many2one('res.company', related='journal_id.company_id', string='Company', store=True, readonly=True,
+ default=lambda self: self.env.company)
+
+ total_entry_encoding = fields.Monetary('Transactions Subtotal', compute='_end_balance', store=True, help="Total of transaction lines.")
+ balance_end = fields.Monetary('Computed Balance', compute='_end_balance', store=True, help='Balance as calculated based on Opening Balance and transaction lines')
+ difference = fields.Monetary(compute='_end_balance', store=True, help="Difference between the computed ending balance and the specified ending balance.")
+
+ line_ids = fields.One2many('account.bank.statement.line', 'statement_id', string='Statement lines', states={'confirm': [('readonly', True)]}, copy=True)
+ move_line_ids = fields.One2many('account.move.line', 'statement_id', string='Entry lines', states={'confirm': [('readonly', True)]})
+ move_line_count = fields.Integer(compute="_get_move_line_count")
+
+ all_lines_reconciled = fields.Boolean(compute='_compute_all_lines_reconciled',
+ help="Technical field indicating if all statement lines are fully reconciled.")
+ user_id = fields.Many2one('res.users', string='Responsible', required=False, default=lambda self: self.env.user)
+ cashbox_start_id = fields.Many2one('account.bank.statement.cashbox', string="Starting Cashbox")
+ cashbox_end_id = fields.Many2one('account.bank.statement.cashbox', string="Ending Cashbox")
+ is_difference_zero = fields.Boolean(compute='_is_difference_zero', string='Is zero', help="Check if difference is zero.")
+ previous_statement_id = fields.Many2one('account.bank.statement', help='technical field to compute starting balance correctly', compute='_get_previous_statement', store=True)
+ is_valid_balance_start = fields.Boolean(string="Is Valid Balance Start", store=True,
+ compute="_compute_is_valid_balance_start",
+ help="Technical field to display a warning message in case starting balance is different than previous ending balance")
+ country_code = fields.Char(related='company_id.country_id.code')
+
+ def write(self, values):
+ res = super(AccountBankStatement, self).write(values)
+ if values.get('date') or values.get('journal'):
+ # If we are changing the date or journal of a bank statement, we have to change its previous_statement_id. This is done
+ # automatically using the compute function, but we also have to change the previous_statement_id of records that were
+ # previously pointing toward us and records that were pointing towards our new previous_statement_id. This is done here
+ # by marking those record as needing to be recomputed.
+ # Note that marking the field is not enough as we also have to recompute all its other fields that are depending on 'previous_statement_id'
+ # hence the need to call modified afterwards.
+ to_recompute = self.search([('previous_statement_id', 'in', self.ids), ('id', 'not in', self.ids), ('journal_id', 'in', self.mapped('journal_id').ids)])
+ if to_recompute:
+ self.env.add_to_compute(self._fields['previous_statement_id'], to_recompute)
+ to_recompute.modified(['previous_statement_id'])
+ next_statements_to_recompute = self.search([('previous_statement_id', 'in', [st.previous_statement_id.id for st in self]), ('id', 'not in', self.ids), ('journal_id', 'in', self.mapped('journal_id').ids)])
+ if next_statements_to_recompute:
+ self.env.add_to_compute(self._fields['previous_statement_id'], next_statements_to_recompute)
+ next_statements_to_recompute.modified(['previous_statement_id'])
+ return res
+
+ @api.model_create_multi
+ def create(self, values):
+ res = super(AccountBankStatement, self).create(values)
+ # Upon bank stmt creation, it is possible that the statement is inserted between two other statements and not at the end
+ # In that case, we have to search for statement that are pointing to the same previous_statement_id as ourselve in order to
+ # change their previous_statement_id to us. This is done by marking the field 'previous_statement_id' to be recomputed for such records.
+ # Note that marking the field is not enough as we also have to recompute all its other fields that are depending on 'previous_statement_id'
+ # hence the need to call modified afterwards.
+ # The reason we are doing this here and not in a compute field is that it is not easy to write dependencies for such field.
+ next_statements_to_recompute = self.search([('previous_statement_id', 'in', [st.previous_statement_id.id for st in res]), ('id', 'not in', res.ids), ('journal_id', 'in', res.journal_id.ids)])
+ if next_statements_to_recompute:
+ self.env.add_to_compute(self._fields['previous_statement_id'], next_statements_to_recompute)
+ next_statements_to_recompute.modified(['previous_statement_id'])
+ return res
+
+ @api.depends('line_ids.is_reconciled')
+ def _compute_all_lines_reconciled(self):
+ for statement in self:
+ statement.all_lines_reconciled = all(st_line.is_reconciled for st_line in statement.line_ids)
+
+ @api.onchange('journal_id')
+ def onchange_journal_id(self):
+ for st_line in self.line_ids:
+ st_line.journal_id = self.journal_id
+ st_line.currency_id = self.journal_id.currency_id or self.company_id.currency_id
+
+ def _check_balance_end_real_same_as_computed(self):
+ ''' Check the balance_end_real (encoded manually by the user) is equals to the balance_end (computed by odoo).
+ In case of a cash statement, the different is set automatically to a profit/loss account.
+ '''
+ for stmt in self:
+ if not stmt.currency_id.is_zero(stmt.difference):
+ if stmt.journal_type == 'cash':
+ st_line_vals = {
+ 'statement_id': stmt.id,
+ 'journal_id': stmt.journal_id.id,
+ 'amount': stmt.difference,
+ 'date': stmt.date,
+ }
+
+ if stmt.difference < 0.0:
+ if not stmt.journal_id.loss_account_id:
+ raise UserError(_('Please go on the %s journal and define a Loss Account. This account will be used to record cash difference.', stmt.journal_id.name))
+
+ st_line_vals['payment_ref'] = _("Cash difference observed during the counting (Loss)")
+ st_line_vals['counterpart_account_id'] = stmt.journal_id.loss_account_id.id
+ else:
+ # statement.difference > 0.0
+ if not stmt.journal_id.profit_account_id:
+ raise UserError(_('Please go on the %s journal and define a Profit Account. This account will be used to record cash difference.', stmt.journal_id.name))
+
+ st_line_vals['payment_ref'] = _("Cash difference observed during the counting (Profit)")
+ st_line_vals['counterpart_account_id'] = stmt.journal_id.profit_account_id.id
+
+ self.env['account.bank.statement.line'].create(st_line_vals)
+ else:
+ balance_end_real = formatLang(self.env, stmt.balance_end_real, currency_obj=stmt.currency_id)
+ balance_end = formatLang(self.env, stmt.balance_end, currency_obj=stmt.currency_id)
+ raise UserError(_(
+ 'The ending balance is incorrect !\nThe expected balance (%(real_balance)s) is different from the computed one (%(computed_balance)s).',
+ real_balance=balance_end_real,
+ computed_balance=balance_end
+ ))
+ return True
+
+ def unlink(self):
+ for statement in self:
+ if statement.state != 'open':
+ raise UserError(_('In order to delete a bank statement, you must first cancel it to delete related journal items.'))
+ # Explicitly unlink bank statement lines so it will check that the related journal entries have been deleted first
+ statement.line_ids.unlink()
+ # Some other bank statements might be link to this one, so in that case we have to switch the previous_statement_id
+ # from that statement to the one linked to this statement
+ next_statement = self.search([('previous_statement_id', '=', statement.id), ('journal_id', '=', statement.journal_id.id)])
+ if next_statement:
+ next_statement.previous_statement_id = statement.previous_statement_id
+ return super(AccountBankStatement, self).unlink()
+
+ # -------------------------------------------------------------------------
+ # CONSTRAINT METHODS
+ # -------------------------------------------------------------------------
+
+ @api.constrains('journal_id')
+ def _check_journal(self):
+ for statement in self:
+ if any(st_line.journal_id != statement.journal_id for st_line in statement.line_ids):
+ raise ValidationError(_('The journal of a bank statement line must always be the same as the bank statement one.'))
+
+ def _constrains_date_sequence(self):
+ # Multiple import methods set the name to things that are not sequences:
+ # i.e. Statement from {date1} to {date2}
+ # It makes this constraint not applicable, and it is less needed on bank statements as it
+ # is only an indication and not some thing legal.
+ return
+
+ # -------------------------------------------------------------------------
+ # BUSINESS METHODS
+ # -------------------------------------------------------------------------
+
+ def open_cashbox_id(self):
+ self.ensure_one()
+ context = dict(self.env.context or {})
+ if context.get('balance'):
+ context['statement_id'] = self.id
+ if context['balance'] == 'start':
+ cashbox_id = self.cashbox_start_id.id
+ elif context['balance'] == 'close':
+ cashbox_id = self.cashbox_end_id.id
+ else:
+ cashbox_id = False
+
+ action = {
+ 'name': _('Cash Control'),
+ 'view_mode': 'form',
+ 'res_model': 'account.bank.statement.cashbox',
+ 'view_id': self.env.ref('account.view_account_bnk_stmt_cashbox_footer').id,
+ 'type': 'ir.actions.act_window',
+ 'res_id': cashbox_id,
+ 'context': context,
+ 'target': 'new'
+ }
+
+ return action
+
+ def button_post(self):
+ ''' Move the bank statements from 'draft' to 'posted'. '''
+ if any(statement.state != 'open' for statement in self):
+ raise UserError(_("Only new statements can be posted."))
+
+ self._check_balance_end_real_same_as_computed()
+
+ for statement in self:
+ if not statement.name:
+ statement._set_next_sequence()
+
+ self.write({'state': 'posted'})
+ lines_of_moves_to_post = self.line_ids.filtered(lambda line: line.move_id.state != 'posted')
+ if lines_of_moves_to_post:
+ lines_of_moves_to_post.move_id._post(soft=False)
+
+ def button_validate(self):
+ if any(statement.state != 'posted' or not statement.all_lines_reconciled for statement in self):
+ raise UserError(_('All the account entries lines must be processed in order to validate the statement.'))
+
+ for statement in self:
+
+ # Chatter.
+ statement.message_post(body=_('Statement %s confirmed.', statement.name))
+
+ # Bank statement report.
+ if statement.journal_id.type == 'bank':
+ content, content_type = self.env.ref('account.action_report_account_statement')._render(statement.id)
+ self.env['ir.attachment'].create({
+ 'name': statement.name and _("Bank Statement %s.pdf", statement.name) or _("Bank Statement.pdf"),
+ 'type': 'binary',
+ 'datas': base64.encodebytes(content),
+ 'res_model': statement._name,
+ 'res_id': statement.id
+ })
+
+ self._check_balance_end_real_same_as_computed()
+ self.write({'state': 'confirm', 'date_done': fields.Datetime.now()})
+
+ def button_validate_or_action(self):
+ if self.journal_type == 'cash' and not self.currency_id.is_zero(self.difference):
+ return self.env['ir.actions.act_window']._for_xml_id('account.action_view_account_bnk_stmt_check')
+
+ return self.button_validate()
+
+ def button_reopen(self):
+ ''' Move the bank statements back to the 'open' state. '''
+ if any(statement.state == 'draft' for statement in self):
+ raise UserError(_("Only validated statements can be reset to new."))
+
+ self.write({'state': 'open'})
+ self.line_ids.move_id.button_draft()
+ self.line_ids.button_undo_reconciliation()
+
+ def button_reprocess(self):
+ """Move the bank statements back to the 'posted' state."""
+ if any(statement.state != 'confirm' for statement in self):
+ raise UserError(_("Only Validated statements can be reset to new."))
+
+ self.write({'state': 'posted', 'date_done': False})
+
+ def button_journal_entries(self):
+ return {
+ 'name': _('Journal Entries'),
+ 'view_mode': 'tree,form',
+ 'res_model': 'account.move',
+ 'view_id': False,
+ 'type': 'ir.actions.act_window',
+ 'domain': [('id', 'in', self.line_ids.move_id.ids)],
+ 'context': {
+ 'journal_id': self.journal_id.id,
+ }
+ }
+
+ def _get_last_sequence_domain(self, relaxed=False):
+ self.ensure_one()
+ where_string = "WHERE journal_id = %(journal_id)s AND name != '/'"
+ param = {'journal_id': self.journal_id.id}
+
+ if not relaxed:
+ domain = [('journal_id', '=', self.journal_id.id), ('id', '!=', self.id or self._origin.id), ('name', '!=', False)]
+ previous_name = self.search(domain + [('date', '<', self.date)], order='date desc', limit=1).name
+ if not previous_name:
+ previous_name = self.search(domain, order='date desc', limit=1).name
+ sequence_number_reset = self._deduce_sequence_number_reset(previous_name)
+ if sequence_number_reset == 'year':
+ where_string += " AND date_trunc('year', date) = date_trunc('year', %(date)s) "
+ param['date'] = self.date
+ elif sequence_number_reset == 'month':
+ where_string += " AND date_trunc('month', date) = date_trunc('month', %(date)s) "
+ param['date'] = self.date
+ return where_string, param
+
+ def _get_starting_sequence(self):
+ self.ensure_one()
+ return "%s %s %04d/%02d/00000" % (self.journal_id.code, _('Statement'), self.date.year, self.date.month)
+
+
+class AccountBankStatementLine(models.Model):
+ _name = "account.bank.statement.line"
+ _inherits = {'account.move': 'move_id'}
+ _description = "Bank Statement Line"
+ _order = "statement_id desc, date, sequence, id desc"
+ _check_company_auto = True
+
+ # FIXME: Fields having the same name in both tables are confusing (partner_id & state). We don't change it because:
+ # - It's a mess to track/fix.
+ # - Some fields here could be simplified when the onchanges will be gone in account.move.
+ # Should be improved in the future.
+
+ # == Business fields ==
+ move_id = fields.Many2one(
+ comodel_name='account.move',
+ string='Journal Entry', required=True, readonly=True, ondelete='cascade',
+ check_company=True)
+ statement_id = fields.Many2one(
+ comodel_name='account.bank.statement',
+ string='Statement', index=True, required=True, ondelete='cascade',
+ check_company=True)
+
+ sequence = fields.Integer(index=True, help="Gives the sequence order when displaying a list of bank statement lines.", default=1)
+ account_number = fields.Char(string='Bank Account Number', help="Technical field used to store the bank account number before its creation, upon the line's processing")
+ partner_name = fields.Char(
+ help="This field is used to record the third party name when importing bank statement in electronic format, "
+ "when the partner doesn't exist yet in the database (or cannot be found).")
+ transaction_type = fields.Char(string='Transaction Type')
+ payment_ref = fields.Char(string='Label', required=True)
+ amount = fields.Monetary(currency_field='currency_id')
+ amount_currency = fields.Monetary(currency_field='foreign_currency_id',
+ help="The amount expressed in an optional other currency if it is a multi-currency entry.")
+ foreign_currency_id = fields.Many2one('res.currency', string='Foreign Currency',
+ help="The optional other currency if it is a multi-currency entry.")
+ amount_residual = fields.Float(string="Residual Amount",
+ compute="_compute_is_reconciled",
+ store=True,
+ help="The amount left to be reconciled on this statement line (signed according to its move lines' balance), expressed in its currency. This is a technical field use to speedup the application of reconciliation models.")
+ currency_id = fields.Many2one('res.currency', string='Journal Currency')
+ partner_id = fields.Many2one(
+ comodel_name='res.partner',
+ string='Partner', ondelete='restrict',
+ domain="['|', ('parent_id','=', False), ('is_company','=',True)]",
+ check_company=True)
+ payment_ids = fields.Many2many(
+ comodel_name='account.payment',
+ relation='account_payment_account_bank_statement_line_rel',
+ string='Auto-generated Payments',
+ help="Payments generated during the reconciliation of this bank statement lines.")
+
+ # == Display purpose fields ==
+ is_reconciled = fields.Boolean(string='Is Reconciled', store=True,
+ compute='_compute_is_reconciled',
+ help="Technical field indicating if the statement line is already reconciled.")
+ state = fields.Selection(related='statement_id.state', string='Status', readonly=True)
+ country_code = fields.Char(related='company_id.country_id.code')
+
+ # -------------------------------------------------------------------------
+ # HELPERS
+ # -------------------------------------------------------------------------
+
+ def _seek_for_lines(self):
+ ''' Helper used to dispatch the journal items between:
+ - The lines using the liquidity account.
+ - The lines using the transfer account.
+ - The lines being not in one of the two previous categories.
+ :return: (liquidity_lines, suspense_lines, other_lines)
+ '''
+ liquidity_lines = self.env['account.move.line']
+ suspense_lines = self.env['account.move.line']
+ other_lines = self.env['account.move.line']
+
+ for line in self.move_id.line_ids:
+ if line.account_id == self.journal_id.default_account_id:
+ liquidity_lines += line
+ elif line.account_id == self.journal_id.suspense_account_id:
+ suspense_lines += line
+ else:
+ other_lines += line
+ return liquidity_lines, suspense_lines, other_lines
+
+ @api.model
+ def _prepare_liquidity_move_line_vals(self):
+ ''' Prepare values to create a new account.move.line record corresponding to the
+ liquidity line (having the bank/cash account).
+ :return: The values to create a new account.move.line record.
+ '''
+ self.ensure_one()
+
+ statement = self.statement_id
+ journal = statement.journal_id
+ company_currency = journal.company_id.currency_id
+ journal_currency = journal.currency_id if journal.currency_id != company_currency else False
+
+ if self.foreign_currency_id and journal_currency:
+ currency_id = journal_currency.id
+ if self.foreign_currency_id == company_currency:
+ amount_currency = self.amount
+ balance = self.amount_currency
+ else:
+ amount_currency = self.amount
+ balance = journal_currency._convert(amount_currency, company_currency, journal.company_id, self.date)
+ elif self.foreign_currency_id and not journal_currency:
+ amount_currency = self.amount_currency
+ balance = self.amount
+ currency_id = self.foreign_currency_id.id
+ elif not self.foreign_currency_id and journal_currency:
+ currency_id = journal_currency.id
+ amount_currency = self.amount
+ balance = journal_currency._convert(amount_currency, journal.company_id.currency_id, journal.company_id, self.date)
+ else:
+ currency_id = company_currency.id
+ amount_currency = self.amount
+ balance = self.amount
+
+ return {
+ 'name': self.payment_ref,
+ 'move_id': self.move_id.id,
+ 'partner_id': self.partner_id.id,
+ 'currency_id': currency_id,
+ 'account_id': journal.default_account_id.id,
+ 'debit': balance > 0 and balance or 0.0,
+ 'credit': balance < 0 and -balance or 0.0,
+ 'amount_currency': amount_currency,
+ }
+
+ @api.model
+ def _prepare_counterpart_move_line_vals(self, counterpart_vals, move_line=None):
+ ''' Prepare values to create a new account.move.line move_line.
+ By default, without specified 'counterpart_vals' or 'move_line', the counterpart line is
+ created using the suspense account. Otherwise, this method is also called during the
+ reconciliation to prepare the statement line's journal entry. In that case,
+ 'counterpart_vals' will be used to create a custom account.move.line (from the reconciliation widget)
+ and 'move_line' will be used to create the counterpart of an existing account.move.line to which
+ the newly created journal item will be reconciled.
+ :param counterpart_vals: A python dictionary containing:
+ 'balance': Optional amount to consider during the reconciliation. If a foreign currency is set on the
+ counterpart line in the same foreign currency as the statement line, then this amount is
+ considered as the amount in foreign currency. If not specified, the full balance is took.
+ This value must be provided if move_line is not.
+ 'amount_residual': The residual amount to reconcile expressed in the company's currency.
+ /!\ This value should be equivalent to move_line.amount_residual except we want
+ to avoid browsing the record when the only thing we need in an overview of the
+ reconciliation, for example in the reconciliation widget.
+ 'amount_residual_currency': The residual amount to reconcile expressed in the foreign's currency.
+ Using this key doesn't make sense without passing 'currency_id' in vals.
+ /!\ This value should be equivalent to move_line.amount_residual_currency except
+ we want to avoid browsing the record when the only thing we need in an overview
+ of the reconciliation, for example in the reconciliation widget.
+ **kwargs: Additional values that need to land on the account.move.line to create.
+ :param move_line: An optional account.move.line move_line representing the counterpart line to reconcile.
+ :return: The values to create a new account.move.line move_line.
+ '''
+ self.ensure_one()
+
+ statement = self.statement_id
+ journal = statement.journal_id
+ company_currency = journal.company_id.currency_id
+ journal_currency = journal.currency_id or company_currency
+ foreign_currency = self.foreign_currency_id or journal_currency or company_currency
+ statement_line_rate = (self.amount_currency / self.amount) if self.amount else 0.0
+
+ balance_to_reconcile = counterpart_vals.pop('balance', None)
+ amount_residual = -counterpart_vals.pop('amount_residual', move_line.amount_residual if move_line else 0.0) \
+ if balance_to_reconcile is None else balance_to_reconcile
+ amount_residual_currency = -counterpart_vals.pop('amount_residual_currency', move_line.amount_residual_currency if move_line else 0.0)\
+ if balance_to_reconcile is None else balance_to_reconcile
+
+ if 'currency_id' in counterpart_vals:
+ currency_id = counterpart_vals['currency_id'] or company_currency.id
+ elif move_line:
+ currency_id = move_line.currency_id.id or company_currency.id
+ else:
+ currency_id = foreign_currency.id
+
+ if currency_id not in (foreign_currency.id, journal_currency.id):
+ currency_id = company_currency.id
+ amount_residual_currency = 0.0
+
+ amounts = {
+ company_currency.id: 0.0,
+ journal_currency.id: 0.0,
+ foreign_currency.id: 0.0,
+ }
+
+ amounts[currency_id] = amount_residual_currency
+ amounts[company_currency.id] = amount_residual
+
+ if currency_id == journal_currency.id and journal_currency != company_currency:
+ if foreign_currency != company_currency:
+ amounts[company_currency.id] = journal_currency._convert(amounts[currency_id], company_currency, journal.company_id, self.date)
+ if statement_line_rate:
+ amounts[foreign_currency.id] = amounts[currency_id] * statement_line_rate
+ elif currency_id == foreign_currency.id and self.foreign_currency_id:
+ if statement_line_rate:
+ amounts[journal_currency.id] = amounts[foreign_currency.id] / statement_line_rate
+ if foreign_currency != company_currency:
+ amounts[company_currency.id] = journal_currency._convert(amounts[journal_currency.id], company_currency, journal.company_id, self.date)
+ else:
+ amounts[journal_currency.id] = company_currency._convert(amounts[company_currency.id], journal_currency, journal.company_id, self.date)
+ if statement_line_rate:
+ amounts[foreign_currency.id] = amounts[journal_currency.id] * statement_line_rate
+
+ if foreign_currency == company_currency and journal_currency != company_currency and self.foreign_currency_id:
+ balance = amounts[foreign_currency.id]
+ else:
+ balance = amounts[company_currency.id]
+
+ if foreign_currency != company_currency and self.foreign_currency_id:
+ amount_currency = amounts[foreign_currency.id]
+ currency_id = foreign_currency.id
+ elif journal_currency != company_currency and not self.foreign_currency_id:
+ amount_currency = amounts[journal_currency.id]
+ currency_id = journal_currency.id
+ else:
+ amount_currency = amounts[company_currency.id]
+ currency_id = company_currency.id
+
+ return {
+ **counterpart_vals,
+ 'name': counterpart_vals.get('name', move_line.name if move_line else ''),
+ 'move_id': self.move_id.id,
+ 'partner_id': self.partner_id.id or (move_line.partner_id.id if move_line else False),
+ 'currency_id': currency_id,
+ 'account_id': counterpart_vals.get('account_id', move_line.account_id.id if move_line else False),
+ 'debit': balance if balance > 0.0 else 0.0,
+ 'credit': -balance if balance < 0.0 else 0.0,
+ 'amount_currency': amount_currency,
+ }
+
+ @api.model
+ def _prepare_move_line_default_vals(self, counterpart_account_id=None):
+ ''' Prepare the dictionary to create the default account.move.lines for the current account.bank.statement.line
+ record.
+ :return: A list of python dictionary to be passed to the account.move.line's 'create' method.
+ '''
+ self.ensure_one()
+
+ if not counterpart_account_id:
+ counterpart_account_id = self.journal_id.suspense_account_id.id
+
+ if not counterpart_account_id:
+ raise UserError(_(
+ "You can't create a new statement line without a suspense account set on the %s journal."
+ ) % self.journal_id.display_name)
+
+ liquidity_line_vals = self._prepare_liquidity_move_line_vals()
+
+ # Ensure the counterpart will have a balance exactly equals to the amount in journal currency.
+ # This avoid some rounding issues when the currency rate between two currencies is not symmetrical.
+ # E.g:
+ # A.convert(amount_a, B) = amount_b
+ # B.convert(amount_b, A) = amount_c != amount_a
+
+ counterpart_vals = {
+ 'name': self.payment_ref,
+ 'account_id': counterpart_account_id,
+ 'amount_residual': liquidity_line_vals['debit'] - liquidity_line_vals['credit'],
+ }
+
+ if self.foreign_currency_id and self.foreign_currency_id != self.company_currency_id:
+ # Ensure the counterpart will have exactly the same amount in foreign currency as the amount set in the
+ # statement line to avoid some rounding issues when making a currency conversion.
+
+ counterpart_vals.update({
+ 'currency_id': self.foreign_currency_id.id,
+ 'amount_residual_currency': self.amount_currency,
+ })
+ elif liquidity_line_vals['currency_id']:
+ # Ensure the counterpart will have a balance exactly equals to the amount in journal currency.
+ # This avoid some rounding issues when the currency rate between two currencies is not symmetrical.
+ # E.g:
+ # A.convert(amount_a, B) = amount_b
+ # B.convert(amount_b, A) = amount_c != amount_a
+
+ counterpart_vals.update({
+ 'currency_id': liquidity_line_vals['currency_id'],
+ 'amount_residual_currency': liquidity_line_vals['amount_currency'],
+ })
+
+ counterpart_line_vals = self._prepare_counterpart_move_line_vals(counterpart_vals)
+ return [liquidity_line_vals, counterpart_line_vals]
+
+ # -------------------------------------------------------------------------
+ # COMPUTE METHODS
+ # -------------------------------------------------------------------------
+
+ @api.depends('journal_id', 'currency_id', 'amount', 'foreign_currency_id', 'amount_currency',
+ 'move_id.to_check',
+ 'move_id.line_ids.account_id', 'move_id.line_ids.amount_currency',
+ 'move_id.line_ids.amount_residual_currency', 'move_id.line_ids.currency_id',
+ 'move_id.line_ids.matched_debit_ids', 'move_id.line_ids.matched_credit_ids')
+ def _compute_is_reconciled(self):
+ ''' Compute the field indicating if the statement lines are already reconciled with something.
+ This field is used for display purpose (e.g. display the 'cancel' button on the statement lines).
+ Also computes the residual amount of the statement line.
+ '''
+ for st_line in self:
+ liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines()
+
+ # Compute residual amount
+ if st_line.to_check:
+ st_line.amount_residual = -st_line.amount_currency if st_line.foreign_currency_id else -st_line.amount
+ elif suspense_lines.account_id.reconcile:
+ st_line.amount_residual = sum(suspense_lines.mapped('amount_residual_currency'))
+ else:
+ st_line.amount_residual = sum(suspense_lines.mapped('amount_currency'))
+
+ # Compute is_reconciled
+ if not st_line.id:
+ # New record: The journal items are not yet there.
+ st_line.is_reconciled = False
+ elif suspense_lines:
+ # In case of the statement line comes from an older version, it could have a residual amount of zero.
+ st_line.is_reconciled = suspense_lines.currency_id.is_zero(st_line.amount_residual)
+ elif st_line.currency_id.is_zero(st_line.amount):
+ st_line.is_reconciled = True
+ else:
+ # The journal entry seems reconciled.
+ st_line.is_reconciled = True
+
+ # -------------------------------------------------------------------------
+ # CONSTRAINT METHODS
+ # -------------------------------------------------------------------------
+
+ @api.constrains('amount', 'amount_currency', 'currency_id', 'foreign_currency_id', 'journal_id')
+ def _check_amounts_currencies(self):
+ ''' Ensure the consistency the specified amounts and the currencies. '''
+
+ for st_line in self:
+ if st_line.journal_id != st_line.statement_id.journal_id:
+ raise ValidationError(_('The journal of a statement line must always be the same as the bank statement one.'))
+ if st_line.foreign_currency_id == st_line.currency_id:
+ raise ValidationError(_("The foreign currency must be different than the journal one: %s", st_line.currency_id.name))
+ if not st_line.foreign_currency_id and st_line.amount_currency:
+ raise ValidationError(_("You can't provide an amount in foreign currency without specifying a foreign currency."))
+
+ # -------------------------------------------------------------------------
+ # LOW-LEVEL METHODS
+ # -------------------------------------------------------------------------
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ # OVERRIDE
+ counterpart_account_ids = []
+
+ for vals in vals_list:
+ statement = self.env['account.bank.statement'].browse(vals['statement_id'])
+ if statement.state != 'open' and self._context.get('check_move_validity', True):
+ raise UserError(_("You can only create statement line in open bank statements."))
+
+ # Force the move_type to avoid inconsistency with residual 'default_move_type' inside the context.
+ vals['move_type'] = 'entry'
+
+ journal = statement.journal_id
+ # Ensure the journal is the same as the statement one.
+ vals['journal_id'] = journal.id
+ vals['currency_id'] = (journal.currency_id or journal.company_id.currency_id).id
+ if 'date' not in vals:
+ vals['date'] = statement.date
+
+ # Hack to force different account instead of the suspense account.
+ counterpart_account_ids.append(vals.pop('counterpart_account_id', None))
+
+ st_lines = super().create(vals_list)
+
+ for i, st_line in enumerate(st_lines):
+ counterpart_account_id = counterpart_account_ids[i]
+
+ to_write = {'statement_line_id': st_line.id}
+ if 'line_ids' not in vals_list[i]:
+ to_write['line_ids'] = [(0, 0, line_vals) for line_vals in st_line._prepare_move_line_default_vals(counterpart_account_id=counterpart_account_id)]
+
+ st_line.move_id.write(to_write)
+
+ return st_lines
+
+ def write(self, vals):
+ # OVERRIDE
+ res = super().write(vals)
+ self._synchronize_to_moves(set(vals.keys()))
+ return res
+
+ def unlink(self):
+ # OVERRIDE to unlink the inherited account.move (move_id field) as well.
+ moves = self.with_context(force_delete=True).mapped('move_id')
+ res = super().unlink()
+ moves.unlink()
+ return res
+
+ # -------------------------------------------------------------------------
+ # SYNCHRONIZATION account.bank.statement.line <-> account.move
+ # -------------------------------------------------------------------------
+
+ def _synchronize_from_moves(self, changed_fields):
+ ''' Update the account.bank.statement.line regarding its related account.move.
+ Also, check both models are still consistent.
+ :param changed_fields: A set containing all modified fields on account.move.
+ '''
+ if self._context.get('skip_account_move_synchronization'):
+ return
+
+ for st_line in self.with_context(skip_account_move_synchronization=True):
+ move = st_line.move_id
+ move_vals_to_write = {}
+ st_line_vals_to_write = {}
+
+ if 'state' in changed_fields:
+ if (st_line.state == 'open' and move.state != 'draft') or (st_line.state == 'posted' and move.state != 'posted'):
+ raise UserError(_(
+ "You can't manually change the state of journal entry %s, as it has been created by bank "
+ "statement %s."
+ ) % (st_line.move_id.display_name, st_line.statement_id.display_name))
+
+ if 'line_ids' in changed_fields:
+ liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines()
+ company_currency = st_line.journal_id.company_id.currency_id
+ journal_currency = st_line.journal_id.currency_id if st_line.journal_id.currency_id != company_currency else False
+
+ if len(liquidity_lines) != 1:
+ raise UserError(_(
+ "The journal entry %s reached an invalid state regarding its related statement line.\n"
+ "To be consistent, the journal entry must always have exactly one journal item involving the "
+ "bank/cash account."
+ ) % st_line.move_id.display_name)
+
+ st_line_vals_to_write.update({
+ 'payment_ref': liquidity_lines.name,
+ 'partner_id': liquidity_lines.partner_id.id,
+ })
+
+ # Update 'amount' according to the liquidity line.
+
+ if journal_currency:
+ st_line_vals_to_write.update({
+ 'amount': liquidity_lines.amount_currency,
+ })
+ else:
+ st_line_vals_to_write.update({
+ 'amount': liquidity_lines.balance,
+ })
+
+ if len(suspense_lines) == 1:
+
+ if journal_currency and suspense_lines.currency_id == journal_currency:
+
+ # The suspense line is expressed in the journal's currency meaning the foreign currency
+ # set on the statement line is no longer needed.
+
+ st_line_vals_to_write.update({
+ 'amount_currency': 0.0,
+ 'foreign_currency_id': False,
+ })
+
+ elif not journal_currency and suspense_lines.currency_id == company_currency:
+
+ # Don't set a specific foreign currency on the statement line.
+
+ st_line_vals_to_write.update({
+ 'amount_currency': 0.0,
+ 'foreign_currency_id': False,
+ })
+
+ else:
+
+ # Update the statement line regarding the foreign currency of the suspense line.
+
+ st_line_vals_to_write.update({
+ 'amount_currency': -suspense_lines.amount_currency,
+ 'foreign_currency_id': suspense_lines.currency_id.id,
+ })
+
+ move_vals_to_write.update({
+ 'partner_id': liquidity_lines.partner_id.id,
+ 'currency_id': (st_line.foreign_currency_id or journal_currency or company_currency).id,
+ })
+
+ move.write(move._cleanup_write_orm_values(move, move_vals_to_write))
+ st_line.write(move._cleanup_write_orm_values(st_line, st_line_vals_to_write))
+
+ def _synchronize_to_moves(self, changed_fields):
+ ''' Update the account.move regarding the modified account.bank.statement.line.
+ :param changed_fields: A list containing all modified fields on account.bank.statement.line.
+ '''
+ if self._context.get('skip_account_move_synchronization'):
+ return
+
+ if not any(field_name in changed_fields for field_name in (
+ 'payment_ref', 'amount', 'amount_currency',
+ 'foreign_currency_id', 'currency_id', 'partner_id',
+ )):
+ return
+
+ for st_line in self.with_context(skip_account_move_synchronization=True):
+ liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines()
+ company_currency = st_line.journal_id.company_id.currency_id
+ journal_currency = st_line.journal_id.currency_id if st_line.journal_id.currency_id != company_currency else False
+
+ line_vals_list = self._prepare_move_line_default_vals()
+ line_ids_commands = [(1, liquidity_lines.id, line_vals_list[0])]
+
+ if suspense_lines:
+ line_ids_commands.append((1, suspense_lines.id, line_vals_list[1]))
+ else:
+ line_ids_commands.append((0, 0, line_vals_list[1]))
+
+ for line in other_lines:
+ line_ids_commands.append((2, line.id))
+
+ st_line.move_id.write({
+ 'partner_id': st_line.partner_id.id,
+ 'currency_id': (st_line.foreign_currency_id or journal_currency or company_currency).id,
+ 'line_ids': line_ids_commands,
+ })
+
+ # -------------------------------------------------------------------------
+ # RECONCILIATION METHODS
+ # -------------------------------------------------------------------------
+
+ def _prepare_reconciliation(self, lines_vals_list, create_payment_for_invoice=False):
+ ''' Helper for the "reconcile" method used to get a full preview of the reconciliation result. This method is
+ quite useful to deal with reconcile models or the reconciliation widget because it ensures the values seen by
+ the user are exactly the values you get after reconciling.
+
+ :param lines_vals_list: See the 'reconcile' method.
+ :param create_payment_for_invoice: A flag indicating the statement line must create payments on the fly during
+ the reconciliation.
+ :return: The diff to be applied on the statement line as a tuple
+ (
+ lines_to_create: The values to create the account.move.line on the statement line.
+ payments_to_create: The values to create the account.payments.
+ open_balance_vals: A dictionary to create the open-balance line or None if the reconciliation is full.
+ existing_lines: The counterpart lines to which the reconciliation will be done.
+ )
+ '''
+
+ self.ensure_one()
+ journal = self.journal_id
+ company_currency = journal.company_id.currency_id
+ foreign_currency = self.foreign_currency_id or journal.currency_id or company_currency
+
+ liquidity_lines, suspense_lines, other_lines = self._seek_for_lines()
+
+ # Ensure the statement line has not yet been already reconciled.
+ # If the move has 'to_check' enabled, it means the statement line has created some lines that
+ # need to be checked later and replaced by the real ones.
+ if not self.move_id.to_check and other_lines:
+ raise UserError(_("The statement line has already been reconciled."))
+
+ # A list of dictionary containing:
+ # - line_vals: The values to create the account.move.line on the statement line.
+ # - payment_vals: The optional values to create a bridge account.payment
+ # - counterpart_line: The optional counterpart line to reconcile with 'line'.
+ reconciliation_overview = []
+
+ total_balance = liquidity_lines.balance
+ total_amount_currency = liquidity_lines.amount_currency
+
+ # Step 1: Split 'lines_vals_list' into two batches:
+ # - The existing account.move.lines that need to be reconciled with the statement line.
+ # => Will be managed at step 2.
+ # - The account.move.lines to be created from scratch.
+ # => Will be managed directly.
+
+ to_browse_ids = []
+ to_process_vals = []
+ for vals in lines_vals_list:
+ # Don't modify the params directly.
+ vals = dict(vals)
+
+ if 'id' in vals:
+ # Existing account.move.line.
+ to_browse_ids.append(vals.pop('id'))
+ to_process_vals.append(vals)
+ else:
+ # Newly created account.move.line from scratch.
+ line_vals = self._prepare_counterpart_move_line_vals(vals)
+ total_balance += line_vals['debit'] - line_vals['credit']
+ total_amount_currency += line_vals['amount_currency']
+
+ reconciliation_overview.append({
+ 'line_vals': line_vals,
+ })
+
+ # Step 2: Browse counterpart lines all in one and process them.
+
+ existing_lines = self.env['account.move.line'].browse(to_browse_ids)
+ for line, counterpart_vals in zip(existing_lines, to_process_vals):
+ line_vals = self._prepare_counterpart_move_line_vals(counterpart_vals, move_line=line)
+ balance = line_vals['debit'] - line_vals['credit']
+ amount_currency = line_vals['amount_currency']
+
+ reconciliation_vals = {
+ 'line_vals': line_vals,
+ 'counterpart_line': line,
+ }
+
+ if create_payment_for_invoice and line.account_internal_type in ('receivable', 'payable'):
+
+ # Prepare values to create a new account.payment.
+ payment_vals = self.env['account.payment.register']\
+ .with_context(active_model='account.move.line', active_ids=line.ids)\
+ .create({
+ 'amount': abs(amount_currency) if line_vals['currency_id'] else abs(balance),
+ 'payment_date': self.date,
+ 'payment_type': 'inbound' if balance < 0.0 else 'outbound',
+ 'journal_id': self.journal_id.id,
+ 'currency_id': (self.foreign_currency_id or self.currency_id).id,
+ })\
+ ._create_payment_vals_from_wizard()
+
+ if payment_vals['payment_type'] == 'inbound':
+ liquidity_account = self.journal_id.payment_debit_account_id
+ else:
+ liquidity_account = self.journal_id.payment_credit_account_id
+
+ # Preserve the rate of the statement line.
+ payment_vals['line_ids'] = [
+ # Receivable / Payable line.
+ (0, 0, {
+ **line_vals,
+ }),
+
+ # Liquidity line.
+ (0, 0, {
+ **line_vals,
+ 'amount_currency': -line_vals['amount_currency'],
+ 'debit': line_vals['credit'],
+ 'credit': line_vals['debit'],
+ 'account_id': liquidity_account.id,
+ }),
+ ]
+
+ # Prepare the line to be reconciled with the payment.
+ if payment_vals['payment_type'] == 'inbound':
+ # Receive money.
+ line_vals['account_id'] = self.journal_id.payment_debit_account_id.id
+ elif payment_vals['payment_type'] == 'outbound':
+ # Send money.
+ line_vals['account_id'] = self.journal_id.payment_credit_account_id.id
+
+ reconciliation_vals['payment_vals'] = payment_vals
+
+ reconciliation_overview.append(reconciliation_vals)
+
+ total_balance += balance
+ total_amount_currency += amount_currency
+
+ # Step 3: Fix rounding issue due to currency conversions.
+ # Add the remaining balance on the first encountered line starting with the custom ones.
+
+ if foreign_currency.is_zero(total_amount_currency) and not company_currency.is_zero(total_balance):
+ vals = reconciliation_overview[0]['line_vals']
+ new_balance = vals['debit'] - vals['credit'] - total_balance
+ vals.update({
+ 'debit': new_balance if new_balance > 0.0 else 0.0,
+ 'credit': -new_balance if new_balance < 0.0 else 0.0,
+ })
+ total_balance = 0.0
+
+ # Step 4: If the journal entry is not yet balanced, create an open balance.
+
+ if self.company_currency_id.round(total_balance):
+ counterpart_vals = {
+ 'name': '%s: %s' % (self.payment_ref, _('Open Balance')),
+ 'balance': -total_balance,
+ 'currency_id': self.company_currency_id.id,
+ }
+
+ partner = self.partner_id or existing_lines.mapped('partner_id')[:1]
+ if partner:
+ if self.amount > 0:
+ open_balance_account = partner.with_company(self.company_id).property_account_receivable_id
+ else:
+ open_balance_account = partner.with_company(self.company_id).property_account_payable_id
+
+ counterpart_vals['account_id'] = open_balance_account.id
+ counterpart_vals['partner_id'] = partner.id
+ else:
+ if self.amount > 0:
+ open_balance_account = self.company_id.partner_id.with_company(self.company_id).property_account_receivable_id
+ else:
+ open_balance_account = self.company_id.partner_id.with_company(self.company_id).property_account_payable_id
+ counterpart_vals['account_id'] = open_balance_account.id
+
+ open_balance_vals = self._prepare_counterpart_move_line_vals(counterpart_vals)
+ else:
+ open_balance_vals = None
+
+ return reconciliation_overview, open_balance_vals
+
+ def reconcile(self, lines_vals_list, to_check=False):
+ ''' Perform a reconciliation on the current account.bank.statement.line with some
+ counterpart account.move.line.
+ If the statement line entry is not fully balanced after the reconciliation, an open balance will be created
+ using the partner.
+
+ :param lines_vals_list: A list of python dictionary containing:
+ 'id': Optional id of an existing account.move.line.
+ For each line having an 'id', a new line will be created in the current statement line.
+ 'balance': Optional amount to consider during the reconciliation. If a foreign currency is set on the
+ counterpart line in the same foreign currency as the statement line, then this amount is
+ considered as the amount in foreign currency. If not specified, the full balance is taken.
+ This value must be provided if 'id' is not.
+ **kwargs: Custom values to be set on the newly created account.move.line.
+ :param to_check: Mark the current statement line as "to_check" (see field for more details).
+ '''
+ self.ensure_one()
+ liquidity_lines, suspense_lines, other_lines = self._seek_for_lines()
+
+ reconciliation_overview, open_balance_vals = self._prepare_reconciliation(lines_vals_list)
+
+ # ==== Manage res.partner.bank ====
+
+ if self.account_number and self.partner_id and not self.partner_bank_id:
+ self.partner_bank_id = self._find_or_create_bank_account()
+
+ # ==== Check open balance ====
+
+ if open_balance_vals:
+ if not open_balance_vals.get('partner_id'):
+ raise UserError(_("Unable to create an open balance for a statement line without a partner set."))
+ if not open_balance_vals.get('account_id'):
+ raise UserError(_("Unable to create an open balance for a statement line because the receivable "
+ "/ payable accounts are missing on the partner."))
+
+ # ==== Create & reconcile payments ====
+ # When reconciling to a receivable/payable account, create an payment on the fly.
+
+ pay_reconciliation_overview = [reconciliation_vals
+ for reconciliation_vals in reconciliation_overview
+ if reconciliation_vals.get('payment_vals')]
+ if pay_reconciliation_overview:
+ payment_vals_list = [reconciliation_vals['payment_vals'] for reconciliation_vals in pay_reconciliation_overview]
+ payments = self.env['account.payment'].create(payment_vals_list)
+
+ payments.action_post()
+
+ for reconciliation_vals, payment in zip(pay_reconciliation_overview, payments):
+ reconciliation_vals['payment'] = payment
+
+ # Reconcile the newly created payment with the counterpart line.
+ (reconciliation_vals['counterpart_line'] + payment.line_ids)\
+ .filtered(lambda line: line.account_id == reconciliation_vals['counterpart_line'].account_id)\
+ .reconcile()
+
+ # ==== Create & reconcile lines on the bank statement line ====
+
+ to_create_commands = [(0, 0, open_balance_vals)] if open_balance_vals else []
+ to_delete_commands = [(2, line.id) for line in suspense_lines + other_lines]
+
+ # Cleanup previous lines.
+ self.move_id.with_context(check_move_validity=False, skip_account_move_synchronization=True, force_delete=True).write({
+ 'line_ids': to_delete_commands + to_create_commands,
+ 'to_check': to_check,
+ })
+
+ line_vals_list = [reconciliation_vals['line_vals'] for reconciliation_vals in reconciliation_overview]
+ new_lines = self.env['account.move.line'].create(line_vals_list)
+ new_lines = new_lines.with_context(skip_account_move_synchronization=True)
+ for reconciliation_vals, line in zip(reconciliation_overview, new_lines):
+ if reconciliation_vals.get('payment'):
+ accounts = (self.journal_id.payment_debit_account_id, self.journal_id.payment_credit_account_id)
+ counterpart_line = reconciliation_vals['payment'].line_ids.filtered(lambda line: line.account_id in accounts)
+ elif reconciliation_vals.get('counterpart_line'):
+ counterpart_line = reconciliation_vals['counterpart_line']
+ else:
+ continue
+
+ (line + counterpart_line).reconcile()
+
+ # Assign partner if needed (for example, when reconciling a statement
+ # line with no partner, with an invoice; assign the partner of this invoice)
+ if not self.partner_id:
+ rec_overview_partners = set(overview['counterpart_line'].partner_id.id
+ for overview in reconciliation_overview
+ if overview.get('counterpart_line') and overview['counterpart_line'].partner_id)
+ if len(rec_overview_partners) == 1:
+ self.line_ids.write({'partner_id': rec_overview_partners.pop()})
+
+ # Refresh analytic lines.
+ self.move_id.line_ids.analytic_line_ids.unlink()
+ self.move_id.line_ids.create_analytic_lines()
+
+ # -------------------------------------------------------------------------
+ # BUSINESS METHODS
+ # -------------------------------------------------------------------------
+
+ def _find_or_create_bank_account(self):
+ bank_account = self.env['res.partner.bank'].search(
+ [('company_id', '=', self.company_id.id), ('acc_number', '=', self.account_number)])
+ if not bank_account:
+ bank_account = self.env['res.partner.bank'].create({
+ 'acc_number': self.account_number,
+ 'partner_id': self.partner_id.id,
+ 'company_id': self.company_id.id,
+ })
+ return bank_account
+
+ def button_undo_reconciliation(self):
+ ''' Undo the reconciliation mades on the statement line and reset their journal items
+ to their original states.
+ '''
+ self.line_ids.remove_move_reconcile()
+ self.payment_ids.unlink()
+
+ for st_line in self:
+ st_line.with_context(force_delete=True).write({
+ 'to_check': False,
+ 'line_ids': [(5, 0)] + [(0, 0, line_vals) for line_vals in st_line._prepare_move_line_default_vals()],
+ })