from odoo import _, fields, models from odoo.exceptions import UserError from odoo.tools import float_is_zero class AccountBankStatement(models.Model): _inherit = "account.bank.statement" accounting_date = fields.Date( string="Accounting Date", help="If set, the accounting entries created during the bank statement " "reconciliation process will be created at this date.\n" "This is useful if the accounting period in which the entries should " "normally be booked is already closed.", states={"open": [("readonly", False)]}, readonly=True, ) def action_bank_reconcile_bank_statements(self): self.ensure_one() bank_stmt_lines = self.mapped("line_ids") return { "type": "ir.actions.client", "tag": "bank_statement_reconciliation_view", "context": { "statement_line_ids": bank_stmt_lines.ids, "company_ids": self.mapped("company_id").ids, }, } class AccountBankStatementLine(models.Model): _inherit = "account.bank.statement.line" # FIXME: is this necessary now? move_name = fields.Char( string="Journal Entry Name", readonly=True, default=False, copy=False, help="Technical field holding the number given to the journal entry, " "automatically set when the statement line is reconciled then " "stored to set the same number again if the line is cancelled, " "set to draft and re-processed again.", ) def process_reconciliation( self, counterpart_aml_dicts=None, payment_aml_rec=None, new_aml_dicts=None ): """Match statement lines with existing payments (eg. checks) and/or payables/receivables (eg. invoices and credit notes) and/or new move lines (eg. write-offs). If any new journal item needs to be created (via new_aml_dicts or counterpart_aml_dicts), a new journal entry will be created and will contain those items, as well as a journal item for the bank statement line. Finally, mark the statement line as reconciled by putting the matched moves ids in the column journal_entry_ids. :param self: browse collection of records that are supposed to have no accounting entries already linked. :param (list of dicts) counterpart_aml_dicts: move lines to create to reconcile with existing payables/receivables. The expected keys are : - 'name' - 'debit' - 'credit' - 'move_line' # The move line to reconcile (partially if specified # debit/credit is lower than move line's credit/debit) :param (list of recordsets) payment_aml_rec: recordset move lines representing existing payments (which are already fully reconciled) :param (list of dicts) new_aml_dicts: move lines to create. The expected keys are : - 'name' - 'debit' - 'credit' - 'account_id' - (optional) 'tax_ids' - (optional) Other account.move.line fields like analytic_account_id or analytics_id - (optional) 'reconcile_model_id' :returns: The journal entries with which the transaction was matched. If there was at least an entry in counterpart_aml_dicts or new_aml_dicts, this list contains the move created by the reconciliation, containing entries for the statement.line (1), the counterpart move lines (0..*) and the new move lines (0..*). """ payable_account_type = self.env.ref("account.data_account_type_payable") receivable_account_type = self.env.ref("account.data_account_type_receivable") suspense_moves_mode = self._context.get("suspense_moves_mode") counterpart_aml_dicts = counterpart_aml_dicts or [] payment_aml_rec = payment_aml_rec or self.env["account.move.line"] new_aml_dicts = new_aml_dicts or [] aml_obj = self.env["account.move.line"] counterpart_moves = self.env["account.move"] # Check and prepare received data if any(rec.statement_id for rec in payment_aml_rec): raise UserError(_("A selected move line was already reconciled.")) for aml_dict in counterpart_aml_dicts: if aml_dict["move_line"].reconciled and not suspense_moves_mode: raise UserError(_("A selected move line was already reconciled.")) if isinstance(aml_dict["move_line"], int): aml_dict["move_line"] = aml_obj.browse(aml_dict["move_line"]) account_types = self.env["account.account.type"] for aml_dict in counterpart_aml_dicts + new_aml_dicts: if aml_dict.get("tax_ids") and isinstance(aml_dict["tax_ids"][0], int): # Transform the value in the format required for One2many and # Many2many fields aml_dict["tax_ids"] = [(4, id, None) for id in aml_dict["tax_ids"]] user_type_id = ( self.env["account.account"] .browse(aml_dict.get("account_id")) .user_type_id ) if ( user_type_id in [payable_account_type, receivable_account_type] and user_type_id not in account_types ): account_types |= user_type_id # Fully reconciled moves are just linked to the bank statement (blue lines), # but the generated move on statement post should be removed and link the # payment one for not having double entry # TODO: To mix already done payments with new ones. Not sure if possible. old_move = self.move_id.with_context(force_delete=True) for aml_rec in payment_aml_rec: aml_rec.with_context(check_move_validity=False).write( {"statement_line_id": self.id} ) # This overwrites the value on each loop, so only one will be linked, but # there's no better solution in this case self.move_id = aml_rec.move_id.id counterpart_moves |= aml_rec.move_id if payment_aml_rec: old_move.button_draft() old_move.unlink() # Create move line(s). Either matching an existing journal entry # (eg. invoice), in which case we reconcile the existing and the new # move lines together, or being a write-off. if counterpart_aml_dicts or new_aml_dicts: counterpart_moves = self._create_counterpart_and_new_aml( counterpart_moves, counterpart_aml_dicts, new_aml_dicts ) elif self.move_name: raise UserError( _( "Operation not allowed. Since your statement line already " "received a number (%s), you cannot reconcile it entirely " "with existing journal entries otherwise it would make a " "gap in the numbering. You should book an entry and make a " "regular revert of it in case you want to cancel it." ) % (self.move_name) ) # create the res.partner.bank if needed if self.account_number and self.partner_id and not self.partner_bank_id: # Search bank account without partner to handle the case the # res.partner.bank already exists but is set on a different partner. self.partner_bank_id = self._find_or_create_bank_account() counterpart_moves._check_balanced() return counterpart_moves def _create_counterpart_and_new_aml( self, counterpart_moves, counterpart_aml_dicts, new_aml_dicts ): aml_obj = self.env["account.move.line"] # Delete previous move_lines self.move_id.line_ids.with_context(force_delete=True).unlink() # Create liquidity line liquidity_aml_dict = self._prepare_liquidity_move_line_vals() aml_obj.with_context(check_move_validity=False).create(liquidity_aml_dict) self.sequence = self.statement_id.line_ids.ids.index(self.id) + 1 counterpart_moves = counterpart_moves | self.move_id # Complete dicts to create both counterpart move lines and write-offs to_create = counterpart_aml_dicts + new_aml_dicts date = self.date or fields.Date.today() for aml_dict in to_create: aml_dict["move_id"] = self.move_id.id aml_dict["partner_id"] = self.partner_id.id aml_dict["statement_line_id"] = self.id self._prepare_move_line_for_currency(aml_dict, date) # Adjust latest counterpart move debit/credit if currency amount is balanced # but company amount is not if self.currency_id != self.company_currency_id: all_amls = to_create + [liquidity_aml_dict] balance_currency = sum([x["amount_currency"] for x in all_amls]) balance = sum([x["debit"] - x["credit"] for x in all_amls]) if float_is_zero( balance_currency, precision_rounding=self.currency_id.rounding ) and not float_is_zero( balance, precision_rounding=self.company_currency_id.rounding ): aml_dict = to_create[-1] if aml_dict["debit"]: aml_dict["debit"] = self.company_currency_id.round( aml_dict["debit"] - balance ) else: aml_dict["credit"] = self.company_currency_id.round( aml_dict["credit"] + balance ) # Create write-offs wo_aml = self.env["account.move.line"] for aml_dict in new_aml_dicts: wo_aml |= aml_obj.with_context(check_move_validity=False).create(aml_dict) analytic_wo_aml = wo_aml.filtered( lambda l: l.analytic_account_id or l.analytic_tag_ids ) # Create counterpart move lines and reconcile them aml_to_reconcile = [] for aml_dict in counterpart_aml_dicts: if aml_dict["move_line"].partner_id.id: aml_dict["partner_id"] = aml_dict["move_line"].partner_id.id aml_dict["account_id"] = aml_dict["move_line"].account_id.id aml_dict["analytic_account_id"] = ( aml_dict["move_line"].analytic_account_id.id or False ) counterpart_move_line = aml_dict.pop("move_line") new_aml = aml_obj.with_context(check_move_validity=False).create(aml_dict) aml_to_reconcile.append((new_aml, counterpart_move_line)) # Post to allow reconcile if self.move_id.state != "posted": self.move_id.with_context( skip_account_move_synchronization=True ).action_post() elif analytic_wo_aml: # if already posted the analytic entry has to be created analytic_wo_aml.create_analytic_lines() # Reconcile new lines with counterpart for new_aml, counterpart_move_line in aml_to_reconcile: (new_aml | counterpart_move_line).reconcile() self._check_invoice_state(counterpart_move_line.move_id) # Needs to be called manually as lines were created 1 by 1 self.move_id.update_lines_tax_exigibility() # record the move name on the statement line to be able to retrieve # it in case of unreconciliation self.write({"move_name": self.move_id.name}) return counterpart_moves def _prepare_move_line_for_currency(self, aml_dict, date): self.ensure_one() company_currency = self.journal_id.company_id.currency_id statement_currency = self.journal_id.currency_id or company_currency st_line_currency = self.foreign_currency_id or statement_currency st_line_currency_rate = ( self.foreign_currency_id and (self.amount_currency / self.amount) or False ) company = self.company_id if st_line_currency.id != company_currency.id: aml_dict["amount_currency"] = aml_dict["debit"] - aml_dict["credit"] aml_dict["currency_id"] = st_line_currency.id if ( self.foreign_currency_id and statement_currency.id == company_currency.id and st_line_currency_rate ): # Statement is in company currency but the transaction is in # foreign currency aml_dict["debit"] = company_currency.round( aml_dict["debit"] / st_line_currency_rate ) aml_dict["credit"] = company_currency.round( aml_dict["credit"] / st_line_currency_rate ) elif self.foreign_currency_id and st_line_currency_rate: # Statement is in foreign currency and the transaction is in # another one aml_dict["debit"] = statement_currency._convert( aml_dict["debit"] / st_line_currency_rate, company_currency, company, date, ) aml_dict["credit"] = statement_currency._convert( aml_dict["credit"] / st_line_currency_rate, company_currency, company, date, ) else: # Statement is in foreign currency and no extra currency is # given for the transaction aml_dict["debit"] = st_line_currency._convert( aml_dict["debit"], company_currency, company, date ) aml_dict["credit"] = st_line_currency._convert( aml_dict["credit"], company_currency, company, date ) elif statement_currency.id != company_currency.id: # Statement is in foreign currency but the transaction is in company # currency prorata_factor = ( aml_dict["debit"] - aml_dict["credit"] ) / self.amount_currency aml_dict["amount_currency"] = prorata_factor * self.amount aml_dict["currency_id"] = statement_currency.id def _check_invoice_state(self, invoice): if invoice.is_invoice(include_receipts=True): invoice._compute_amount() def button_undo_reconciliation(self): """Handle the case when the reconciliation was done against a direct payment with the bank account as counterpart. This may be the case for payments made in previous versions of Odoo. """ handled = self.env[self._name] for record in self: if record.move_id.payment_id: # The reconciliation was done against a blue line (existing move) # We remove the link on the current existing move, preserving it, # and recreate a new move as if the statement line was new record.move_id.line_ids.statement_line_id = False statement = record.statement_id journal = statement.journal_id line_vals_list = record._prepare_move_line_default_vals() new_move = self.env["account.move"].create( { "move_type": "entry", "statement_line_id": record.id, "ref": statement.name, "date": record.date, "journal_id": journal.id, "partner_id": record.partner_id.id, "currency_id": ( journal.currency_id or journal.company_id.currency_id ).id, "line_ids": [(0, 0, line_vals) for line_vals in line_vals_list], } ) new_move.action_post() record.move_id = new_move.id handled += record return super( AccountBankStatementLine, self - handled ).button_undo_reconciliation()