# Fusion Accounting - Bank Reconciliation Widget # Original implementation for Fusion Accounting module import json import markupsafe from collections import defaultdict from contextlib import contextmanager from odoo import _, api, fields, models, Command from odoo.addons.web.controllers.utils import clean_action from odoo.exceptions import UserError, RedirectWarning from odoo.tools.misc import formatLang class FusionBankRecWidget(models.Model): """Manages the reconciliation process for a single bank statement line. This transient-like model orchestrates the matching of a bank statement entry against existing journal items, write-off entries, and other counterparts. It exists only in memory and is never persisted. The widget maintains a collection of 'bank.rec.widget.line' entries representing the reconciliation breakdown: the original bank entry, matched journal items, manual adjustments, tax entries, exchange differences, and a system-generated balancing entry. """ _name = "bank.rec.widget" _description = "Fusion bank reconciliation widget" _auto = False _table_query = "0" # ========================================================================= # FIELDS: Statement Line Reference # ========================================================================= st_line_id = fields.Many2one(comodel_name='account.bank.statement.line') move_id = fields.Many2one( related='st_line_id.move_id', depends=['st_line_id'], ) st_line_checked = fields.Boolean( related='st_line_id.move_id.checked', depends=['st_line_id'], ) st_line_is_reconciled = fields.Boolean( related='st_line_id.is_reconciled', depends=['st_line_id'], ) st_line_journal_id = fields.Many2one( related='st_line_id.journal_id', depends=['st_line_id'], ) st_line_transaction_details = fields.Html( compute='_compute_st_line_transaction_details', ) partner_name = fields.Char(related='st_line_id.partner_name') # ========================================================================= # FIELDS: Currency & Company # ========================================================================= transaction_currency_id = fields.Many2one( comodel_name='res.currency', compute='_compute_transaction_currency_id', ) journal_currency_id = fields.Many2one( comodel_name='res.currency', compute='_compute_journal_currency_id', ) company_id = fields.Many2one( comodel_name='res.company', related='st_line_id.company_id', depends=['st_line_id'], ) country_code = fields.Char( related='company_id.country_id.code', depends=['company_id'], ) company_currency_id = fields.Many2one( string="Wizard Company Currency", related='company_id.currency_id', depends=['st_line_id'], ) # ========================================================================= # FIELDS: Partner & Lines # ========================================================================= partner_id = fields.Many2one( comodel_name='res.partner', string="Partner", compute='_compute_partner_id', store=True, readonly=False, ) line_ids = fields.One2many( comodel_name='bank.rec.widget.line', inverse_name='wizard_id', compute='_compute_line_ids', compute_sudo=False, store=True, readonly=False, ) # ========================================================================= # FIELDS: Reconciliation Models # ========================================================================= available_reco_model_ids = fields.Many2many( comodel_name='account.reconcile.model', compute='_compute_available_reco_model_ids', store=True, readonly=False, ) selected_reco_model_id = fields.Many2one( comodel_name='account.reconcile.model', compute='_compute_selected_reco_model_id', ) matching_rules_allow_auto_reconcile = fields.Boolean() # ========================================================================= # FIELDS: State & Display # ========================================================================= state = fields.Selection( selection=[ ('invalid', "Invalid"), ('valid', "Valid"), ('reconciled', "Reconciled"), ], compute='_compute_state', store=True, help=( "Invalid: Cannot validate because the suspense account is still present.\n" "Valid: Ready for validation.\n" "Reconciled: Already processed, no action needed." ), ) is_multi_currency = fields.Boolean(compute='_compute_is_multi_currency') # ========================================================================= # FIELDS: JS Interface # ========================================================================= selected_aml_ids = fields.Many2many( comodel_name='account.move.line', compute='_compute_selected_aml_ids', ) todo_command = fields.Json(store=False) return_todo_command = fields.Json(store=False) form_index = fields.Char() # ========================================================================= # COMPUTE METHODS # ========================================================================= @api.depends('st_line_id') def _compute_line_ids(self): """Build the initial set of reconciliation entries from the statement line. Creates the liquidity entry (the bank-side journal item) and loads any existing reconciled counterparts. For already-reconciled lines, exchange difference entries are separated out for clear display. """ for rec_widget in self: if not rec_widget.st_line_id: rec_widget.line_ids = [Command.clear()] continue # Start with the liquidity (bank account) entry orm_ops = [ Command.clear(), Command.create(rec_widget._lines_prepare_liquidity_line()), ] # Load existing counterpart entries for reconciled lines _liq_items, _suspense_items, matched_items = rec_widget.st_line_id._seek_for_lines() for matched_item in matched_items: partial_links = matched_item.matched_debit_ids + matched_item.matched_credit_ids fx_correction_items = ( partial_links.exchange_move_id.line_ids .filtered(lambda item: item.account_id != matched_item.account_id) ) if rec_widget.state == 'reconciled' and fx_correction_items: # Display the original amounts separately from exchange adjustments adjusted_balance = matched_item.balance - sum(fx_correction_items.mapped('balance')) adjusted_foreign = matched_item.amount_currency - sum(fx_correction_items.mapped('amount_currency')) orm_ops.append(Command.create( rec_widget._lines_prepare_aml_line( matched_item, balance=adjusted_balance, amount_currency=adjusted_foreign, ) )) for fx_item in fx_correction_items: orm_ops.append(Command.create(rec_widget._lines_prepare_aml_line(fx_item))) else: orm_ops.append(Command.create(rec_widget._lines_prepare_aml_line(matched_item))) rec_widget.line_ids = orm_ops rec_widget._lines_add_auto_balance_line() @api.depends('st_line_id') def _compute_available_reco_model_ids(self): """Find reconciliation models applicable to the current journal and company.""" for rec_widget in self: if not rec_widget.st_line_id: rec_widget.available_reco_model_ids = [Command.clear()] continue stmt_entry = rec_widget.st_line_id applicable_models = self.env['account.reconcile.model'].search([ ('rule_type', '=', 'writeoff_button'), ('company_id', '=', stmt_entry.company_id.id), '|', ('match_journal_ids', '=', False), ('match_journal_ids', '=', stmt_entry.journal_id.id), ]) # Keep models that are general-purpose or use at most one journal applicable_models = applicable_models.filtered( lambda model: ( model.counterpart_type == 'general' or len(model.line_ids.journal_id) <= 1 ) ) rec_widget.available_reco_model_ids = [Command.set(applicable_models.ids)] @api.depends('line_ids.reconcile_model_id') def _compute_selected_reco_model_id(self): """Track which write-off reconciliation model is currently applied.""" for rec_widget in self: active_models = rec_widget.line_ids.reconcile_model_id.filtered( lambda model: model.rule_type == 'writeoff_button' ) rec_widget.selected_reco_model_id = ( active_models.id if len(active_models) == 1 else None ) @api.depends('st_line_id', 'line_ids.account_id') def _compute_state(self): """Determine the reconciliation state of the widget. - 'reconciled': Statement line is already fully matched - 'invalid': Suspense account is still present (not fully allocated) - 'valid': All amounts allocated to real accounts, ready to validate """ for rec_widget in self: if not rec_widget.st_line_id: rec_widget.state = 'invalid' elif rec_widget.st_line_id.is_reconciled: rec_widget.state = 'reconciled' else: holding_account = rec_widget.st_line_id.journal_id.suspense_account_id accounts_in_use = rec_widget.line_ids.account_id rec_widget.state = 'invalid' if holding_account in accounts_in_use else 'valid' @api.depends('st_line_id') def _compute_journal_currency_id(self): """Resolve the effective currency of the bank journal.""" for rec_widget in self: journal = rec_widget.st_line_id.journal_id rec_widget.journal_currency_id = journal.currency_id or journal.company_id.currency_id @api.depends('st_line_id') def _compute_st_line_transaction_details(self): """Render the raw transaction details as formatted HTML.""" for rec_widget in self: rec_widget.st_line_transaction_details = rec_widget._render_transaction_details() @api.depends('st_line_id') def _compute_transaction_currency_id(self): """Determine the transaction currency (foreign currency if set, else journal currency).""" for rec_widget in self: rec_widget.transaction_currency_id = ( rec_widget.st_line_id.foreign_currency_id or rec_widget.journal_currency_id ) @api.depends('st_line_id') def _compute_partner_id(self): """Auto-detect the partner from the statement line data.""" for rec_widget in self: if rec_widget.st_line_id: rec_widget.partner_id = rec_widget.st_line_id._retrieve_partner() else: rec_widget.partner_id = None @api.depends('company_id') def _compute_is_multi_currency(self): """Check if the user has multi-currency access rights.""" self.is_multi_currency = self.env.user.has_groups('base.group_multi_currency') @api.depends('company_id', 'line_ids.source_aml_id') def _compute_selected_aml_ids(self): """Expose the set of journal items currently matched in this widget.""" for rec_widget in self: rec_widget.selected_aml_ids = [Command.set(rec_widget.line_ids.source_aml_id.ids)] # ========================================================================= # TRANSACTION DETAILS RENDERING # ========================================================================= def _render_transaction_details(self): """Convert structured transaction details into a readable HTML tree. Parses the JSON/dict transaction_details field from the statement line and renders it as a nested HTML list, filtering out empty values. """ self.ensure_one() raw_details = self.st_line_id.transaction_details if not raw_details: return None parsed = json.loads(raw_details) if isinstance(raw_details, str) else raw_details def _build_html_node(label, data): """Recursively build HTML list items from a data structure.""" if not data: return "" if isinstance(data, dict): children = markupsafe.Markup("").join( _build_html_node(f"{key}: ", val) for key, val in data.items() ) rendered_value = markupsafe.Markup('
    %s
') % children if children else "" elif isinstance(data, (list, tuple)): children = markupsafe.Markup("").join( _build_html_node(f"{pos}: ", val) for pos, val in enumerate(data, start=1) ) rendered_value = markupsafe.Markup('
    %s
') % children if children else "" else: rendered_value = data if not rendered_value: return "" return markupsafe.Markup( '
  • ' '%(label)s%(content)s' '
  • ' ) % {'label': label, 'content': rendered_value} root_html = _build_html_node('', parsed) return markupsafe.Markup("
      %s
    ") % root_html # ========================================================================= # ONCHANGE HANDLERS # ========================================================================= @api.onchange('todo_command') def _onchange_todo_command(self): """Dispatch JS-triggered commands to the appropriate handler method. The JS frontend sends commands via the todo_command field. Each command specifies a method_name that maps to a _js_action_* method, along with optional args and kwargs. """ self.ensure_one() pending_cmd = self.todo_command self.todo_command = None self.return_todo_command = None # Force-load line_ids to prevent stale cache issues during updates self._ensure_loaded_lines() action_method = getattr(self, f'_js_action_{pending_cmd["method_name"]}') action_method(*pending_cmd.get('args', []), **pending_cmd.get('kwargs', {})) # ========================================================================= # LOW-LEVEL OVERRIDES # ========================================================================= @api.model def new(self, values=None, origin=None, ref=None): """Override to ensure line_ids are loaded immediately after creation.""" widget = super().new(values=values, origin=origin, ref=ref) # Trigger line_ids evaluation to prevent cache inconsistencies # when subsequent operations modify the One2many widget.line_ids return widget # ========================================================================= # INITIALIZATION # ========================================================================= @api.model def fetch_initial_data(self): """Prepare field metadata and default values for the JS frontend. Returns a dictionary with field definitions (including related fields for One2many and Many2many) and initial values for bootstrapping the reconciliation widget on the client side. """ field_defs = self.fields_get() view_attrs = self.env['ir.ui.view']._get_view_field_attributes() for fname, field_obj in self._fields.items(): if field_obj.type == 'one2many': child_fields = self[fname].fields_get(attributes=view_attrs) # Remove the back-reference field from child definitions child_fields.pop(field_obj.inverse_name, None) field_defs[fname]['relatedFields'] = child_fields # Resolve nested Many2many related fields for child_fname, child_field_obj in self[fname]._fields.items(): if child_field_obj.type == "many2many": nested_model = self.env[child_field_obj.comodel_name] field_defs[fname]['relatedFields'][child_fname]['relatedFields'] = ( nested_model.fields_get( allfields=['id', 'display_name'], attributes=view_attrs, ) ) elif field_obj.name == 'available_reco_model_ids': field_defs[fname]['relatedFields'] = self[fname].fields_get( allfields=['id', 'display_name'], attributes=view_attrs, ) # Mark todo_command as triggering onChange field_defs['todo_command']['onChange'] = True # Build initial values defaults = {} for fname, field_obj in self._fields.items(): if field_obj.type == 'one2many': defaults[fname] = [] else: defaults[fname] = field_obj.convert_to_read(self[fname], self, {}) return { 'initial_values': defaults, 'fields': field_defs, } # ========================================================================= # LINE PREPARATION METHODS # ========================================================================= def _ensure_loaded_lines(self): """Force evaluation of line_ids to prevent ORM cache inconsistencies. When a One2many field's value is replaced with new Command.create entries, accessing the field beforehand can cause stale records to persist alongside the new ones. Triggering evaluation here avoids that problem. """ self.line_ids def _lines_turn_auto_balance_into_manual_line(self, entry): """Promote an auto-balance entry to a manual entry when the user edits it.""" if entry.flag == 'auto_balance': entry.flag = 'manual' def _lines_get_line_in_edit_form(self): """Return the widget entry currently selected for editing, if any.""" self.ensure_one() if not self.form_index: return None return self.line_ids.filtered(lambda rec: rec.index == self.form_index) def _lines_prepare_aml_line(self, move_line, **extra_vals): """Build creation values for a widget entry linked to a journal item.""" self.ensure_one() return { 'flag': 'aml', 'source_aml_id': move_line.id, **extra_vals, } def _lines_prepare_liquidity_line(self): """Build creation values for the liquidity (bank account) entry. The liquidity entry represents the bank-side journal item. When the journal uses a different currency from the transaction, the amounts are sourced from the appropriate line of the statement's move. """ self.ensure_one() liq_item, _suspense_items, _matched_items = self.st_line_id._seek_for_lines() return self._lines_prepare_aml_line(liq_item, flag='liquidity') def _lines_prepare_auto_balance_line(self): """Compute values for the automatic balancing entry. Calculates the remaining unallocated amount across all current entries and produces an auto-balance entry to close the gap. The target account is chosen based on the partner's receivable/payable configuration, or falls back to the journal's suspense account. """ self.ensure_one() stmt_entry = self.st_line_id # Retrieve the statement line's accounting amounts txn_amount, txn_currency, jrnl_amount, _jrnl_currency, comp_amount, _comp_currency = ( self.st_line_id._get_accounting_amounts_and_currencies() ) # Calculate the remaining amounts to be balanced pending_foreign = -txn_amount pending_company = -comp_amount for entry in self.line_ids: if entry.flag in ('liquidity', 'auto_balance'): continue pending_company -= entry.balance # Convert to transaction currency using the appropriate rate txn_to_jrnl_rate = abs(txn_amount / jrnl_amount) if jrnl_amount else 0.0 txn_to_comp_rate = abs(txn_amount / comp_amount) if comp_amount else 0.0 if entry.currency_id == self.transaction_currency_id: pending_foreign -= entry.amount_currency elif entry.currency_id == self.journal_currency_id: pending_foreign -= txn_currency.round(entry.amount_currency * txn_to_jrnl_rate) else: pending_foreign -= txn_currency.round(entry.balance * txn_to_comp_rate) # Determine the target account based on partner configuration target_account = None current_partner = self.partner_id if current_partner: label = _("Open balance of %(amount)s", amount=formatLang( self.env, txn_amount, currency_obj=txn_currency, )) scoped_partner = current_partner.with_company(stmt_entry.company_id) has_customer_role = current_partner.customer_rank and not current_partner.supplier_rank has_vendor_role = current_partner.supplier_rank and not current_partner.customer_rank if has_customer_role: target_account = scoped_partner.property_account_receivable_id elif has_vendor_role: target_account = scoped_partner.property_account_payable_id elif stmt_entry.amount > 0: target_account = scoped_partner.property_account_receivable_id else: target_account = scoped_partner.property_account_payable_id if not target_account: label = stmt_entry.payment_ref target_account = stmt_entry.journal_id.suspense_account_id return { 'flag': 'auto_balance', 'account_id': target_account.id, 'name': label, 'amount_currency': pending_foreign, 'balance': pending_company, } def _lines_add_auto_balance_line(self): """Refresh the auto-balance entry to keep the reconciliation balanced. Removes any existing auto-balance entry and creates a new one if the remaining balance is non-zero. The entry is always placed last. """ # Remove existing auto-balance entries orm_ops = [ Command.unlink(entry.id) for entry in self.line_ids if entry.flag == 'auto_balance' ] # Create a fresh auto-balance if needed balance_data = self._lines_prepare_auto_balance_line() if not self.company_currency_id.is_zero(balance_data['balance']): orm_ops.append(Command.create(balance_data)) self.line_ids = orm_ops def _lines_prepare_new_aml_line(self, move_line, **extra_vals): """Build values for adding a new journal item as a reconciliation counterpart.""" return self._lines_prepare_aml_line( move_line, flag='new_aml', currency_id=move_line.currency_id.id, amount_currency=-move_line.amount_residual_currency, balance=-move_line.amount_residual, source_amount_currency=-move_line.amount_residual_currency, source_balance=-move_line.amount_residual, **extra_vals, ) def _lines_check_partial_amount(self, entry): """Check if a partial reconciliation should be applied to the given entry. Determines whether the matched journal item exceeds the remaining transaction amount and, if so, computes the adjusted amounts needed for a partial match. Returns None if no partial is needed. """ if entry.flag != 'new_aml': return None fx_entry = self.line_ids.filtered( lambda rec: rec.flag == 'exchange_diff' and rec.source_aml_id == entry.source_aml_id ) balance_data = self._lines_prepare_auto_balance_line() remaining_comp = balance_data['balance'] current_comp = entry.balance + fx_entry.balance # Check if there's excess in company currency comp_cur = self.company_currency_id excess_debit_comp = ( comp_cur.compare_amounts(remaining_comp, 0) < 0 and comp_cur.compare_amounts(current_comp, 0) > 0 and comp_cur.compare_amounts(current_comp, -remaining_comp) > 0 ) excess_credit_comp = ( comp_cur.compare_amounts(remaining_comp, 0) > 0 and comp_cur.compare_amounts(current_comp, 0) < 0 and comp_cur.compare_amounts(-current_comp, remaining_comp) > 0 ) remaining_foreign = balance_data['amount_currency'] current_foreign = entry.amount_currency entry_cur = entry.currency_id # Check if there's excess in the entry's currency excess_debit_foreign = ( entry_cur.compare_amounts(remaining_foreign, 0) < 0 and entry_cur.compare_amounts(current_foreign, 0) > 0 and entry_cur.compare_amounts(current_foreign, -remaining_foreign) > 0 ) excess_credit_foreign = ( entry_cur.compare_amounts(remaining_foreign, 0) > 0 and entry_cur.compare_amounts(current_foreign, 0) < 0 and entry_cur.compare_amounts(-current_foreign, remaining_foreign) > 0 ) if entry_cur == self.transaction_currency_id: if not (excess_debit_foreign or excess_credit_foreign): return None adjusted_foreign = current_foreign + remaining_foreign # Use the bank transaction rate for conversion txn_amount, _txn_cur, _jrnl_amt, _jrnl_cur, comp_amount, _comp_cur = ( self.st_line_id._get_accounting_amounts_and_currencies() ) conversion_rate = abs(comp_amount / txn_amount) if txn_amount else 0.0 adjusted_comp_total = entry.company_currency_id.round(adjusted_foreign * conversion_rate) adjusted_entry_comp = entry.company_currency_id.round( adjusted_comp_total * abs(entry.balance) / abs(current_comp) ) adjusted_fx_comp = adjusted_comp_total - adjusted_entry_comp return { 'exchange_diff_line': fx_entry, 'amount_currency': adjusted_foreign, 'balance': adjusted_entry_comp, 'exchange_balance': adjusted_fx_comp, } elif excess_debit_comp or excess_credit_comp: adjusted_comp_total = current_comp + remaining_comp # Use the original journal item's rate original_rate = abs(entry.source_amount_currency) / abs(entry.source_balance) adjusted_entry_comp = entry.company_currency_id.round( adjusted_comp_total * abs(entry.balance) / abs(current_comp) ) adjusted_fx_comp = adjusted_comp_total - adjusted_entry_comp adjusted_foreign = entry_cur.round(adjusted_entry_comp * original_rate) return { 'exchange_diff_line': fx_entry, 'amount_currency': adjusted_foreign, 'balance': adjusted_entry_comp, 'exchange_balance': adjusted_fx_comp, } return None def _do_amounts_apply_for_early_payment(self, pending_foreign, discount_total): """Check if the remaining amount exactly matches the early payment discount.""" return self.transaction_currency_id.compare_amounts(pending_foreign, discount_total) == 0 def _lines_check_apply_early_payment_discount(self): """Attempt to apply early payment discount terms to matched journal items. Examines all currently matched journal items to see if their invoices offer early payment discounts. If the remaining balance equals the total discount amount, applies the discount by creating early_payment entries. Returns True if the discount was applied, False otherwise. """ matched_entries = self.line_ids.filtered(lambda rec: rec.flag == 'new_aml') # Compute the remaining balance with and without matched entries balance_data = self._lines_prepare_auto_balance_line() residual_foreign_excl = ( balance_data['amount_currency'] + sum(matched_entries.mapped('amount_currency')) ) residual_comp_excl = ( balance_data['balance'] + sum(matched_entries.mapped('balance')) ) residual_foreign_incl = ( residual_foreign_excl - sum(matched_entries.mapped('source_amount_currency')) ) residual_foreign = residual_foreign_incl uniform_currency = matched_entries.currency_id == self.transaction_currency_id has_discount_eligible = False discount_entries = [] discount_total = 0.0 for matched_entry in matched_entries: source_item = matched_entry.source_aml_id if source_item.move_id._is_eligible_for_early_payment_discount( self.transaction_currency_id, self.st_line_id.date ): has_discount_eligible = True discount_total += source_item.amount_currency - source_item.discount_amount_currency discount_entries.append({ 'aml': source_item, 'amount_currency': matched_entry.amount_currency, 'balance': matched_entry.balance, }) # Remove existing early payment entries orm_ops = [ Command.unlink(entry.id) for entry in self.line_ids if entry.flag == 'early_payment' ] discount_applied = False if ( uniform_currency and has_discount_eligible and self._do_amounts_apply_for_early_payment(residual_foreign, discount_total) ): # Reset matched entries to their full original amounts for matched_entry in matched_entries: matched_entry.amount_currency = matched_entry.source_amount_currency matched_entry.balance = matched_entry.source_balance # Generate the early payment discount entries discount_breakdown = ( self.env['account.move']._get_invoice_counterpart_amls_for_early_payment_discount( discount_entries, residual_comp_excl - sum(matched_entries.mapped('source_balance')), ) ) for category_entries in discount_breakdown.values(): for entry_vals in category_entries: orm_ops.append(Command.create({ 'flag': 'early_payment', 'account_id': entry_vals['account_id'], 'date': self.st_line_id.date, 'name': entry_vals['name'], 'partner_id': entry_vals['partner_id'], 'currency_id': entry_vals['currency_id'], 'amount_currency': entry_vals['amount_currency'], 'balance': entry_vals['balance'], 'analytic_distribution': entry_vals.get('analytic_distribution'), 'tax_ids': entry_vals.get('tax_ids', []), 'tax_tag_ids': entry_vals.get('tax_tag_ids', []), 'tax_repartition_line_id': entry_vals.get('tax_repartition_line_id'), 'group_tax_id': entry_vals.get('group_tax_id'), })) discount_applied = True if orm_ops: self.line_ids = orm_ops return discount_applied def _lines_check_apply_partial_matching(self): """Attempt partial matching on the most recently added journal item. If multiple items are matched and the last one overshoots the remaining balance, reduce it to create a partial reconciliation. Also resets any previous partials except on manually modified entries. Returns True if a partial was applied, False otherwise. """ matched_entries = self.line_ids.filtered(lambda rec: rec.flag == 'new_aml') if not matched_entries: return False final_entry = matched_entries[-1] # Reset prior partials on unmodified entries reset_ops = [] affected_entries = self.env['bank.rec.widget.line'] for matched_entry in matched_entries: has_partial = matched_entry.display_stroked_amount_currency or matched_entry.display_stroked_balance if has_partial and not matched_entry.manually_modified: reset_ops.append(Command.update(matched_entry.id, { 'amount_currency': matched_entry.source_amount_currency, 'balance': matched_entry.source_balance, })) affected_entries |= matched_entry if reset_ops: self.line_ids = reset_ops self._lines_recompute_exchange_diff(affected_entries) # Check if the last entry should be partially matched partial_data = self._lines_check_partial_amount(final_entry) if partial_data: final_entry.amount_currency = partial_data['amount_currency'] final_entry.balance = partial_data['balance'] fx_entry = partial_data['exchange_diff_line'] if fx_entry: fx_entry.balance = partial_data['exchange_balance'] if fx_entry.currency_id == self.company_currency_id: fx_entry.amount_currency = fx_entry.balance return True return False def _lines_load_new_amls(self, move_lines, reco_model=None): """Create widget entries for a set of journal items to be reconciled.""" orm_ops = [] model_ref = {'reconcile_model_id': reco_model.id} if reco_model else {} for move_line in move_lines: entry_vals = self._lines_prepare_new_aml_line(move_line, **model_ref) orm_ops.append(Command.create(entry_vals)) if orm_ops: self.line_ids = orm_ops # ========================================================================= # TAX COMPUTATION # ========================================================================= def _prepare_base_line_for_taxes_computation(self, entry): """Convert a widget entry into the format expected by account.tax computation. Handles both tax-exclusive and tax-inclusive modes based on the force_price_included_taxes flag. """ self.ensure_one() applied_taxes = entry.tax_ids tax_usage = applied_taxes[0].type_tax_use if applied_taxes else None is_refund_context = ( (tax_usage == 'sale' and entry.balance > 0.0) or (tax_usage == 'purchase' and entry.balance < 0.0) ) if entry.force_price_included_taxes and applied_taxes: computation_mode = 'total_included' base_value = entry.tax_base_amount_currency else: computation_mode = 'total_excluded' base_value = entry.amount_currency return self.env['account.tax']._prepare_base_line_for_taxes_computation( entry, price_unit=base_value, quantity=1.0, is_refund=is_refund_context, special_mode=computation_mode, ) def _prepare_tax_line_for_taxes_computation(self, entry): """Convert a tax widget entry for the tax computation engine.""" self.ensure_one() return self.env['account.tax']._prepare_tax_line_for_taxes_computation(entry) def _lines_prepare_tax_line(self, tax_data): """Build creation values for a tax entry from computed tax data.""" self.ensure_one() tax_rep = self.env['account.tax.repartition.line'].browse(tax_data['tax_repartition_line_id']) description = tax_rep.tax_id.name if self.st_line_id.payment_ref: description = f'{description} - {self.st_line_id.payment_ref}' entry_currency = self.env['res.currency'].browse(tax_data['currency_id']) foreign_amount = tax_data['amount_currency'] comp_amounts = self.st_line_id._prepare_counterpart_amounts_using_st_line_rate( entry_currency, None, foreign_amount, ) return { 'flag': 'tax_line', 'account_id': tax_data['account_id'], 'date': self.st_line_id.date, 'name': description, 'partner_id': tax_data['partner_id'], 'currency_id': entry_currency.id, 'amount_currency': foreign_amount, 'balance': comp_amounts['balance'], 'analytic_distribution': tax_data['analytic_distribution'], 'tax_repartition_line_id': tax_rep.id, 'tax_ids': tax_data['tax_ids'], 'tax_tag_ids': tax_data['tax_tag_ids'], 'group_tax_id': tax_data['group_tax_id'], } def _lines_recompute_taxes(self): """Recalculate all tax entries based on the current manual base entries. Uses Odoo's tax computation engine to determine the correct tax amounts, then updates/creates/deletes tax entries accordingly. """ self.ensure_one() TaxEngine = self.env['account.tax'] # Collect base and tax entries base_entries = self.line_ids.filtered( lambda rec: rec.flag == 'manual' and not rec.tax_repartition_line_id ) tax_entries = self.line_ids.filtered(lambda rec: rec.flag == 'tax_line') base_data = [self._prepare_base_line_for_taxes_computation(rec) for rec in base_entries] tax_data = [self._prepare_tax_line_for_taxes_computation(rec) for rec in tax_entries] # Run the tax computation pipeline TaxEngine._add_tax_details_in_base_lines(base_data, self.company_id) TaxEngine._round_base_lines_tax_details(base_data, self.company_id) TaxEngine._add_accounting_data_in_base_lines_tax_details( base_data, self.company_id, include_caba_tags=True, ) computed_taxes = TaxEngine._prepare_tax_lines( base_data, self.company_id, tax_lines=tax_data, ) orm_ops = [] # Update base entries with new tax tags and amounts for base_rec, updates in computed_taxes['base_lines_to_update']: rec = base_rec['record'] new_foreign = updates['amount_currency'] comp_amounts = self.st_line_id._prepare_counterpart_amounts_using_st_line_rate( rec.currency_id, rec.source_balance, new_foreign, ) orm_ops.append(Command.update(rec.id, { 'balance': comp_amounts['balance'], 'amount_currency': new_foreign, 'tax_tag_ids': updates['tax_tag_ids'], })) # Remove obsolete tax entries for obsolete_tax in computed_taxes['tax_lines_to_delete']: orm_ops.append(Command.unlink(obsolete_tax['record'].id)) # Add newly computed tax entries for new_tax_data in computed_taxes['tax_lines_to_add']: orm_ops.append(Command.create(self._lines_prepare_tax_line(new_tax_data))) # Update existing tax entries with new amounts for existing_tax, grouping, updates in computed_taxes['tax_lines_to_update']: refreshed_vals = self._lines_prepare_tax_line({**grouping, **updates}) orm_ops.append(Command.update(existing_tax['record'].id, { 'amount_currency': refreshed_vals['amount_currency'], 'balance': refreshed_vals['balance'], })) self.line_ids = orm_ops # ========================================================================= # EXCHANGE DIFFERENCE HANDLING # ========================================================================= def _get_key_mapping_aml_and_exchange_diff(self, entry): """Return the key used to associate exchange difference entries with their source.""" if entry.source_aml_id: return 'source_aml_id', entry.source_aml_id.id return None, None def _reorder_exchange_and_aml_lines(self): """Reorder entries so each exchange difference follows its corresponding match.""" fx_entries = self.line_ids.filtered(lambda rec: rec.flag == 'exchange_diff') source_to_fx = defaultdict(lambda: self.env['bank.rec.widget.line']) for fx_entry in fx_entries: mapping_key = self._get_key_mapping_aml_and_exchange_diff(fx_entry) source_to_fx[mapping_key] |= fx_entry ordered_ids = [] for entry in self.line_ids: if entry in fx_entries: continue ordered_ids.append(entry.id) entry_key = self._get_key_mapping_aml_and_exchange_diff(entry) if entry_key in source_to_fx: ordered_ids.extend(source_to_fx[entry_key].mapped('id')) self.line_ids = self.env['bank.rec.widget.line'].browse(ordered_ids) def _remove_related_exchange_diff_lines(self, target_entries): """Remove exchange difference entries that are linked to the specified entries.""" unlink_ops = [] for target in target_entries: if target.flag == 'exchange_diff': continue ref_field, ref_id = self._get_key_mapping_aml_and_exchange_diff(target) if not ref_field: continue for fx_entry in self.line_ids: if fx_entry[ref_field] and fx_entry[ref_field].id == ref_id: unlink_ops.append(Command.unlink(fx_entry.id)) if unlink_ops: self.line_ids = unlink_ops def _lines_get_account_balance_exchange_diff(self, entry_currency, comp_balance, foreign_amount): """Compute the exchange difference amount and determine the target account. Compares the balance at the bank transaction rate vs. the original journal item rate to determine the foreign exchange gain/loss. Returns (account, exchange_diff_balance) tuple. """ # Compute balance using the bank transaction rate rate_adjusted = self.st_line_id._prepare_counterpart_amounts_using_st_line_rate( entry_currency, comp_balance, foreign_amount, ) adjusted_balance = rate_adjusted['balance'] if entry_currency == self.company_currency_id and self.transaction_currency_id != self.company_currency_id: # Reconciliation uses the statement line rate; keep the original balance adjusted_balance = comp_balance elif entry_currency != self.company_currency_id and self.transaction_currency_id == self.company_currency_id: # Convert through the foreign currency to handle rate discrepancies adjusted_balance = entry_currency._convert( foreign_amount, self.transaction_currency_id, self.company_id, self.st_line_id.date, ) fx_diff = adjusted_balance - comp_balance if self.company_currency_id.is_zero(fx_diff): return self.env['account.account'], 0.0 # Select the appropriate exchange gain/loss account if fx_diff > 0.0: fx_account = self.company_id.expense_currency_exchange_account_id else: fx_account = self.company_id.income_currency_exchange_account_id return fx_account, fx_diff def _lines_get_exchange_diff_values(self, entry): """Compute exchange difference entry values for a matched journal item.""" if entry.flag != 'new_aml': return [] fx_account, fx_amount = self._lines_get_account_balance_exchange_diff( entry.currency_id, entry.balance, entry.amount_currency, ) if entry.currency_id.is_zero(fx_amount): return [] return [{ 'flag': 'exchange_diff', 'source_aml_id': entry.source_aml_id.id, 'account_id': fx_account.id, 'date': entry.date, 'name': _("Exchange Difference: %s", entry.name), 'partner_id': entry.partner_id.id, 'currency_id': entry.currency_id.id, 'amount_currency': fx_amount if entry.currency_id == self.company_currency_id else 0.0, 'balance': fx_amount, 'source_amount_currency': entry.amount_currency, 'source_balance': fx_amount, }] def _lines_recompute_exchange_diff(self, target_entries): """Recalculate exchange difference entries for the specified matched items. Creates new exchange difference entries or updates existing ones as needed. Also cleans up orphaned exchange differences for deleted entries. """ self.ensure_one() # Clean up exchange diffs for entries that were removed removed_entries = target_entries - self.line_ids self._remove_related_exchange_diff_lines(removed_entries) target_entries = target_entries - removed_entries existing_fx = self.line_ids.filtered( lambda rec: rec.flag == 'exchange_diff' ).grouped('source_aml_id') orm_ops = [] needs_reorder = False for entry in target_entries: fx_values_list = self._lines_get_exchange_diff_values(entry) if entry.source_aml_id and entry.source_aml_id in existing_fx: # Update existing exchange difference entry for fx_vals in fx_values_list: orm_ops.append(Command.update(existing_fx[entry.source_aml_id].id, fx_vals)) else: # Create new exchange difference entry for fx_vals in fx_values_list: orm_ops.append(Command.create(fx_vals)) needs_reorder = True if orm_ops: self.line_ids = orm_ops if needs_reorder: self._reorder_exchange_and_aml_lines() # ========================================================================= # RECONCILE MODEL WRITE-OFF PREPARATION # ========================================================================= def _lines_prepare_reco_model_write_off_vals(self, reco_model, writeoff_data): """Build widget entry values from a reconcile model's write-off specification.""" self.ensure_one() comp_amounts = self.st_line_id._prepare_counterpart_amounts_using_st_line_rate( self.transaction_currency_id, None, writeoff_data['amount_currency'], ) return { 'flag': 'manual', 'account_id': writeoff_data['account_id'], 'date': self.st_line_id.date, 'name': writeoff_data['name'], 'partner_id': writeoff_data['partner_id'], 'currency_id': writeoff_data['currency_id'], 'amount_currency': writeoff_data['amount_currency'], 'balance': comp_amounts['balance'], 'tax_base_amount_currency': writeoff_data['amount_currency'], 'force_price_included_taxes': True, 'reconcile_model_id': reco_model.id, 'analytic_distribution': writeoff_data['analytic_distribution'], 'tax_ids': writeoff_data['tax_ids'], } # ========================================================================= # LINE VALUE CHANGE HANDLERS # ========================================================================= def _line_value_changed_account_id(self, entry): """Handle account change on a widget entry.""" self.ensure_one() self._lines_turn_auto_balance_into_manual_line(entry) if entry.flag not in ('tax_line', 'early_payment') and entry.tax_ids: self._lines_recompute_taxes() self._lines_add_auto_balance_line() def _line_value_changed_date(self, entry): """Handle date change - propagate to statement line if editing liquidity entry.""" self.ensure_one() if entry.flag == 'liquidity' and entry.date: self.st_line_id.date = entry.date self._action_reload_liquidity_line() self.return_todo_command = {'reset_global_info': True, 'reset_record': True} def _line_value_changed_ref(self, entry): """Handle reference change on the liquidity entry.""" self.ensure_one() if entry.flag == 'liquidity': self.st_line_id.move_id.ref = entry.ref self._action_reload_liquidity_line() self.return_todo_command = {'reset_record': True} def _line_value_changed_narration(self, entry): """Handle narration change on the liquidity entry.""" self.ensure_one() if entry.flag == 'liquidity': self.st_line_id.move_id.narration = entry.narration self._action_reload_liquidity_line() self.return_todo_command = {'reset_record': True} def _line_value_changed_name(self, entry): """Handle label/name change - propagate to statement line if liquidity.""" self.ensure_one() if entry.flag == 'liquidity': self.st_line_id.payment_ref = entry.name self._action_reload_liquidity_line() self.return_todo_command = {'reset_global_info': True, 'reset_record': True} return self._lines_turn_auto_balance_into_manual_line(entry) def _line_value_changed_amount_transaction_currency(self, entry): """Handle transaction currency amount change on the liquidity entry.""" self.ensure_one() if entry.flag != 'liquidity': return if entry.transaction_currency_id != self.journal_currency_id: self.st_line_id.amount_currency = entry.amount_transaction_currency self.st_line_id.foreign_currency_id = entry.transaction_currency_id else: self.st_line_id.amount_currency = 0.0 self.st_line_id.foreign_currency_id = None self._action_reload_liquidity_line() self.return_todo_command = {'reset_global_info': True, 'reset_record': True} def _line_value_changed_transaction_currency_id(self, entry): """Handle transaction currency change.""" self._line_value_changed_amount_transaction_currency(entry) def _line_value_changed_amount_currency(self, entry): """Handle foreign currency amount change on any entry. For liquidity entries, propagates to the statement line. For matched entries (new_aml), enforces bounds and adjusts the company-currency balance using the appropriate rate. For manual entries, converts using the statement line or market rate. """ self.ensure_one() if entry.flag == 'liquidity': self.st_line_id.amount = entry.amount_currency self._action_reload_liquidity_line() self.return_todo_command = {'reset_global_info': True, 'reset_record': True} return self._lines_turn_auto_balance_into_manual_line(entry) direction = -1 if entry.amount_currency < 0.0 else 1 if entry.flag == 'new_aml': # Clamp to valid range: same sign as source, not exceeding source clamped = direction * max(0.0, min(abs(entry.amount_currency), abs(entry.source_amount_currency))) entry.amount_currency = clamped entry.manually_modified = True # Reset to full amount if user clears the field if not entry.amount_currency: entry.amount_currency = entry.source_amount_currency elif not entry.amount_currency: entry.amount_currency = 0.0 # Compute the corresponding company-currency balance if entry.currency_id == entry.company_currency_id: entry.balance = entry.amount_currency elif entry.flag == 'new_aml': if entry.currency_id.compare_amounts( abs(entry.amount_currency), abs(entry.source_amount_currency) ) == 0.0: entry.balance = entry.source_balance elif entry.source_balance: source_rate = abs(entry.source_amount_currency / entry.source_balance) entry.balance = entry.company_currency_id.round(entry.amount_currency / source_rate) else: entry.balance = 0.0 elif entry.flag in ('manual', 'early_payment', 'tax_line'): if entry.currency_id in (self.transaction_currency_id, self.journal_currency_id): entry.balance = self.st_line_id._prepare_counterpart_amounts_using_st_line_rate( entry.currency_id, None, entry.amount_currency, )['balance'] else: entry.balance = entry.currency_id._convert( entry.amount_currency, self.company_currency_id, self.company_id, self.st_line_id.date, ) if entry.flag not in ('tax_line', 'early_payment'): if entry.tax_ids: entry.force_price_included_taxes = False self._lines_recompute_taxes() self._lines_recompute_exchange_diff(entry) self._lines_add_auto_balance_line() def _line_value_changed_balance(self, entry): """Handle company-currency balance change on any entry. Similar to amount_currency changes but operates in company currency. For matched entries, enforces the same clamping rules. """ self.ensure_one() if entry.flag == 'liquidity': self.st_line_id.amount = entry.balance self._action_reload_liquidity_line() self.return_todo_command = {'reset_global_info': True, 'reset_record': True} return self._lines_turn_auto_balance_into_manual_line(entry) direction = -1 if entry.balance < 0.0 else 1 if entry.flag == 'new_aml': clamped = direction * max(0.0, min(abs(entry.balance), abs(entry.source_balance))) entry.balance = clamped entry.manually_modified = True if not entry.balance: entry.balance = entry.source_balance elif not entry.balance: entry.balance = 0.0 if entry.currency_id == entry.company_currency_id: entry.amount_currency = entry.balance self._line_value_changed_amount_currency(entry) elif entry.flag == 'exchange_diff': self._lines_add_auto_balance_line() else: self._lines_recompute_exchange_diff(entry) self._lines_add_auto_balance_line() def _line_value_changed_currency_id(self, entry): """Handle currency change - triggers amount recomputation.""" self.ensure_one() self._line_value_changed_amount_currency(entry) def _line_value_changed_tax_ids(self, entry): """Handle tax selection change on a manual entry. When taxes are added, enables tax-inclusive mode. When taxes are removed, restores the original base amount if it was in inclusive mode. """ self.ensure_one() self._lines_turn_auto_balance_into_manual_line(entry) if entry.tax_ids: if not entry.tax_base_amount_currency: entry.tax_base_amount_currency = entry.amount_currency entry.force_price_included_taxes = True else: if entry.force_price_included_taxes: entry.amount_currency = entry.tax_base_amount_currency self._line_value_changed_amount_currency(entry) entry.tax_base_amount_currency = False self._lines_recompute_taxes() self._lines_add_auto_balance_line() def _line_value_changed_partner_id(self, entry): """Handle partner change on an entry. For liquidity entries, propagates to the statement line. For other entries, attempts to set the appropriate receivable/payable account based on the partner's configuration and outstanding balances. """ self.ensure_one() if entry.flag == 'liquidity': self.st_line_id.partner_id = entry.partner_id self._action_reload_liquidity_line() self.return_todo_command = {'reset_global_info': True, 'reset_record': True} return self._lines_turn_auto_balance_into_manual_line(entry) suggested_account = None if entry.partner_id: is_customer_only = entry.partner_id.customer_rank and not entry.partner_id.supplier_rank is_vendor_only = entry.partner_id.supplier_rank and not entry.partner_id.customer_rank recv_balance_zero = entry.partner_currency_id.is_zero(entry.partner_receivable_amount) pay_balance_zero = entry.partner_currency_id.is_zero(entry.partner_payable_amount) if is_customer_only or (not recv_balance_zero and pay_balance_zero): suggested_account = entry.partner_receivable_account_id elif is_vendor_only or (recv_balance_zero and not pay_balance_zero): suggested_account = entry.partner_payable_account_id elif self.st_line_id.amount < 0.0: suggested_account = entry.partner_payable_account_id or entry.partner_receivable_account_id else: suggested_account = entry.partner_receivable_account_id or entry.partner_payable_account_id if suggested_account: entry.account_id = suggested_account self._line_value_changed_account_id(entry) elif entry.flag not in ('tax_line', 'early_payment') and entry.tax_ids: self._lines_recompute_taxes() self._lines_add_auto_balance_line() def _line_value_changed_analytic_distribution(self, entry): """Handle analytic distribution change - recompute taxes if analytics affect them.""" self.ensure_one() self._lines_turn_auto_balance_into_manual_line(entry) if entry.flag not in ('tax_line', 'early_payment') and any(t.analytic for t in entry.tax_ids): self._lines_recompute_taxes() self._lines_add_auto_balance_line() # ========================================================================= # CORE ACTIONS # ========================================================================= def _action_trigger_matching_rules(self): """Run automatic reconciliation rules against the current statement line. Searches for applicable reconcile models and applies the first match, which may add journal items, apply a write-off model, or flag for auto-reconciliation. """ self.ensure_one() if self.st_line_id.is_reconciled: return applicable_rules = self.env['account.reconcile.model'].search([ ('rule_type', '!=', 'writeoff_button'), ('company_id', '=', self.company_id.id), '|', ('match_journal_ids', '=', False), ('match_journal_ids', '=', self.st_line_id.journal_id.id), ]) match_result = applicable_rules._apply_rules(self.st_line_id, self.partner_id) if match_result.get('amls'): matched_model = match_result['model'] permit_partial = match_result.get('status') != 'write_off' self._action_add_new_amls( match_result['amls'], reco_model=matched_model, allow_partial=permit_partial, ) if match_result.get('status') == 'write_off': self._action_select_reconcile_model(match_result['model']) if match_result.get('auto_reconcile'): self.matching_rules_allow_auto_reconcile = True return match_result def _prepare_embedded_views_data(self): """Build configuration for the embedded journal item list views. Returns domain, filters, and context for the JS frontend to render the list of available journal items for matching. """ self.ensure_one() stmt_entry = self.st_line_id view_context = { 'search_view_ref': 'fusion_accounting.view_account_move_line_search_bank_rec_widget', 'list_view_ref': 'fusion_accounting.view_account_move_line_list_bank_rec_widget', } if self.partner_id: view_context['search_default_partner_id'] = self.partner_id.id # Build dynamic filter for Customer/Vendor vs Misc separation journal = stmt_entry.journal_id payment_account_ids = set() for acct in journal._get_journal_inbound_outstanding_payment_accounts() - journal.default_account_id: payment_account_ids.add(acct.id) for acct in journal._get_journal_outbound_outstanding_payment_accounts() - journal.default_account_id: payment_account_ids.add(acct.id) receivable_payable_domain = [ '|', '&', ('account_id.account_type', 'in', ('asset_receivable', 'liability_payable')), ('payment_id', '=', False), '&', ('account_id', 'in', tuple(payment_account_ids)), ('payment_id', '!=', False), ] filter_specs = [ { 'name': 'receivable_payable_matching', 'description': _("Customer/Vendor"), 'domain': str(receivable_payable_domain), 'no_separator': True, 'is_default': False, }, { 'name': 'misc_matching', 'description': _("Misc"), 'domain': str(['!'] + receivable_payable_domain), 'is_default': False, }, ] return { 'amls': { 'domain': stmt_entry._get_default_amls_matching_domain(), 'dynamic_filters': filter_specs, 'context': view_context, }, } def _action_mount_st_line(self, stmt_entry): """Load a statement line into the widget and trigger matching rules.""" self.ensure_one() self.st_line_id = stmt_entry self.form_index = self.line_ids[0].index if self.state == 'reconciled' else None self._action_trigger_matching_rules() def _action_reload_liquidity_line(self): """Reload the widget after the liquidity entry (statement line) was modified.""" self.ensure_one() self = self.with_context(default_st_line_id=self.st_line_id.id) self.invalidate_model() # Force-load lines to prevent cache issues self.line_ids self._action_trigger_matching_rules() # Restore focus to the liquidity entry liq_entry = self.line_ids.filtered(lambda rec: rec.flag == 'liquidity') self._js_action_mount_line_in_edit(liq_entry.index) def _action_clear_manual_operations_form(self): """Close the manual operations form panel.""" self.form_index = None def _action_remove_lines(self, target_entries): """Remove the specified entries and rebalance. After removal, recomputes taxes if needed, checks for early payment discounts or partial matching opportunities, and refreshes the auto-balance entry. """ self.ensure_one() if not target_entries: return taxes_affected = bool(target_entries.tax_ids) had_matched_items = any(entry.flag == 'new_aml' for entry in target_entries) self.line_ids = [Command.unlink(entry.id) for entry in target_entries] self._remove_related_exchange_diff_lines(target_entries) if taxes_affected: self._lines_recompute_taxes() if had_matched_items and not self._lines_check_apply_early_payment_discount(): self._lines_check_apply_partial_matching() self._lines_add_auto_balance_line() self._action_clear_manual_operations_form() def _action_add_new_amls(self, move_lines, reco_model=None, allow_partial=True): """Add journal items as reconciliation counterparts. Filters out items that are already present, creates widget entries, computes exchange differences, checks for early payment discounts and partial matching, then rebalances. """ self.ensure_one() already_loaded = set( self.line_ids .filtered(lambda rec: rec.flag in ('new_aml', 'aml', 'liquidity')) .source_aml_id ) new_items = move_lines.filtered(lambda item: item not in already_loaded) if not new_items: return self._lines_load_new_amls(new_items, reco_model=reco_model) newly_added = self.line_ids.filtered( lambda rec: rec.flag == 'new_aml' and rec.source_aml_id in new_items ) self._lines_recompute_exchange_diff(newly_added) if not self._lines_check_apply_early_payment_discount() and allow_partial: self._lines_check_apply_partial_matching() self._lines_add_auto_balance_line() self._action_clear_manual_operations_form() def _action_remove_new_amls(self, move_lines): """Remove specific matched journal items from the reconciliation.""" self.ensure_one() entries_to_remove = self.line_ids.filtered( lambda rec: rec.flag == 'new_aml' and rec.source_aml_id in move_lines ) self._action_remove_lines(entries_to_remove) def _action_select_reconcile_model(self, reco_model): """Apply a reconciliation model's write-off lines. Removes entries from any previously selected model, then creates new entries based on the selected model's configuration. For sale/purchase models, creates an invoice/bill instead. """ self.ensure_one() # Remove entries from previously applied models self.line_ids = [ Command.unlink(entry.id) for entry in self.line_ids if ( entry.flag not in ('new_aml', 'liquidity') and entry.reconcile_model_id and entry.reconcile_model_id != reco_model ) ] self._lines_recompute_taxes() if reco_model.to_check: self.st_line_id.move_id.checked = False self.invalidate_recordset(fnames=['st_line_checked']) # Compute available balance for the model's write-off lines balance_data = self._lines_prepare_auto_balance_line() available_amount = balance_data['amount_currency'] writeoff_specs = reco_model._apply_lines_for_bank_widget( available_amount, self.partner_id, self.st_line_id, ) if reco_model.rule_type == 'writeoff_button' and reco_model.counterpart_type in ('sale', 'purchase'): # Create an invoice/bill from the write-off specification created_doc = self._create_invoice_from_write_off_values(reco_model, writeoff_specs) action_data = { 'type': 'ir.actions.act_window', 'res_model': 'account.move', 'context': {'create': False}, 'view_mode': 'form', 'res_id': created_doc.id, } self.return_todo_command = clean_action(action_data, self.env) else: # Create write-off entries directly self.line_ids = [ Command.create(self._lines_prepare_reco_model_write_off_vals(reco_model, spec)) for spec in writeoff_specs ] self._lines_recompute_taxes() self._lines_add_auto_balance_line() def _create_invoice_from_write_off_values(self, reco_model, writeoff_specs): """Create an invoice or bill from reconcile model write-off data. Determines the move type based on the amount direction and model type, then creates the invoice with appropriate line items. """ target_journal = reco_model.line_ids.journal_id[:1] invoice_items = [] cumulative_amount = 0.0 pct_of_statement = 0.0 for spec in writeoff_specs: spec_copy = dict(spec) if 'percentage_st_line' not in spec_copy: cumulative_amount -= spec_copy['amount_currency'] pct_of_statement += spec_copy.pop('percentage_st_line', 0) spec_copy.pop('currency_id', None) spec_copy.pop('partner_id', None) spec_copy.pop('reconcile_model_id', None) invoice_items.append(spec_copy) stmt_amount = ( self.st_line_id.amount_currency if self.st_line_id.foreign_currency_id else self.st_line_id.amount ) cumulative_amount += self.transaction_currency_id.round(stmt_amount * pct_of_statement) # Determine invoice type from amount direction and model type if reco_model.counterpart_type == 'sale': doc_type = 'out_invoice' if cumulative_amount > 0 else 'out_refund' else: doc_type = 'in_invoice' if cumulative_amount < 0 else 'in_refund' sign_for_price = 1 if cumulative_amount < 0.0 else -1 item_commands = [] for item_vals in invoice_items: raw_total = sign_for_price * item_vals.pop('amount_currency') applicable_taxes = self.env['account.tax'].browse(item_vals['tax_ids'][0][2]) item_vals['price_unit'] = self._get_invoice_price_unit_from_price_total( raw_total, applicable_taxes, ) item_commands.append(Command.create(item_vals)) doc_vals = { 'invoice_date': self.st_line_id.date, 'move_type': doc_type, 'partner_id': self.st_line_id.partner_id.id, 'currency_id': self.transaction_currency_id.id, 'payment_reference': self.st_line_id.payment_ref, 'invoice_line_ids': item_commands, } if target_journal: doc_vals['journal_id'] = target_journal.id created_doc = self.env['account.move'].create(doc_vals) if not created_doc.currency_id.is_zero(created_doc.amount_total - cumulative_amount): created_doc._check_total_amount(abs(cumulative_amount)) return created_doc def _get_invoice_price_unit_from_price_total(self, total_with_tax, applicable_taxes): """Reverse-compute the unit price from a tax-inclusive total.""" self.ensure_one() tax_details = applicable_taxes._get_tax_details( total_with_tax, 1.0, precision_rounding=self.transaction_currency_id.rounding, rounding_method=self.company_id.tax_calculation_rounding_method, special_mode='total_included', ) included_tax_total = sum( detail['tax_amount'] for detail in tax_details['taxes_data'] if detail['tax'].price_include ) return tax_details['total_excluded'] + included_tax_total # ========================================================================= # VALIDATION # ========================================================================= def _validation_lines_vals(self, orm_ops, fx_correction_data, reconciliation_pairs): """Build journal item creation commands from the current widget entries. Processes each widget entry into an account.move.line creation command, squashing exchange difference amounts into their corresponding matched items. Tracks which entries need reconciliation against counterparts. """ non_liq_entries = self.line_ids.filtered(lambda rec: rec.flag != 'liquidity') unique_partners = non_liq_entries.partner_id partner_for_liq = unique_partners if len(unique_partners) == 1 else self.env['res.partner'] fx_by_source = self.line_ids.filtered( lambda rec: rec.flag == 'exchange_diff' ).grouped('source_aml_id') for entry in self.line_ids: if entry.flag == 'exchange_diff': continue entry_foreign = entry.amount_currency entry_comp = entry.balance if entry.flag == 'new_aml': sequence_idx = len(orm_ops) + 1 reconciliation_pairs.append((sequence_idx, entry.source_aml_id)) related_fx = fx_by_source.get(entry.source_aml_id) if related_fx: fx_correction_data[sequence_idx] = { 'amount_residual': related_fx.balance, 'amount_residual_currency': related_fx.amount_currency, 'analytic_distribution': related_fx.analytic_distribution, } entry_foreign += related_fx.amount_currency entry_comp += related_fx.balance # Determine partner: use unified partner for liquidity/auto_balance assigned_partner = ( partner_for_liq.id if entry.flag in ('liquidity', 'auto_balance') else entry.partner_id.id ) orm_ops.append(Command.create(entry._get_aml_values( sequence=len(orm_ops) + 1, partner_id=assigned_partner, amount_currency=entry_foreign, balance=entry_comp, ))) def _action_validate(self): """Finalize the reconciliation by writing journal items and reconciling. Creates the final set of journal items on the statement line's move, handles exchange difference moves, performs the reconciliation, and updates partner/bank account information. """ self.ensure_one() non_liq_entries = self.line_ids.filtered(lambda rec: rec.flag != 'liquidity') unique_partners = non_liq_entries.partner_id partner_for_move = unique_partners if len(unique_partners) == 1 else self.env['res.partner'] reconciliation_pairs = [] orm_ops = [] fx_correction_data = {} self._validation_lines_vals(orm_ops, fx_correction_data, reconciliation_pairs) stmt_entry = self.st_line_id target_move = stmt_entry.move_id # Write the finalized journal items to the move editable_move = target_move.with_context(force_delete=True, skip_readonly_check=True) editable_move.write({ 'partner_id': partner_for_move.id, 'line_ids': [Command.clear()] + orm_ops, }) # Map sequences to the created journal items MoveLine = self.env['account.move.line'] items_by_seq = editable_move.line_ids.grouped('sequence') paired_items = [ (items_by_seq[seq_idx], counterpart_item) for seq_idx, counterpart_item in reconciliation_pairs ] all_involved_ids = tuple({ item_id for created_item, counterpart in paired_items for item_id in (created_item + counterpart).ids }) # Handle exchange difference moves fx_moves = None items_with_fx = MoveLine if fx_correction_data: fx_move_specs = [] for created_item, counterpart in paired_items: prefetched_item = created_item.with_prefetch(all_involved_ids) prefetched_counterpart = counterpart.with_prefetch(all_involved_ids) fx_amounts = fx_correction_data.get(prefetched_item.sequence, {}) fx_analytics = fx_amounts.pop('analytic_distribution', False) if fx_amounts: # Determine which side gets the exchange difference if fx_amounts['amount_residual'] * prefetched_item.amount_residual > 0: fx_target = prefetched_item else: fx_target = prefetched_counterpart fx_move_specs.append(fx_target._prepare_exchange_difference_move_vals( [fx_amounts], exchange_date=max(prefetched_item.date, prefetched_counterpart.date), exchange_analytic_distribution=fx_analytics, )) items_with_fx += prefetched_item fx_moves = MoveLine._create_exchange_difference_moves(fx_move_specs) # Execute the reconciliation plan self.env['account.move.line'].with_context(no_exchange_difference=True)._reconcile_plan([ (created_item + counterpart).with_prefetch(all_involved_ids) for created_item, counterpart in paired_items ]) # Link exchange moves to the appropriate partial records for idx, fx_item in enumerate(items_with_fx): fx_move = fx_moves[idx] for side in ('debit', 'credit'): partial_records = fx_item[f'matched_{side}_ids'].filtered( lambda partial: partial[f'{side}_move_id'].move_id != fx_move ) partial_records.exchange_move_id = fx_move # Update partner on the statement line editable_stmt = stmt_entry.with_context( skip_account_move_synchronization=True, skip_readonly_check=True, ) editable_stmt.partner_id = partner_for_move # Create or link partner bank account if applicable if stmt_entry.account_number and stmt_entry.partner_id: editable_stmt.partner_bank_id = ( stmt_entry._find_or_create_bank_account() or stmt_entry.partner_bank_id ) # Refresh analytic tracking target_move.line_ids.analytic_line_ids.unlink() target_move.line_ids.with_context(validate_analytic=True)._create_analytic_lines() @contextmanager def _action_validate_method(self): """Context manager wrapping validation to handle post-validation cleanup. Saves a reference to the statement line before validation (which invalidates the current record), then reloads everything after. """ self.ensure_one() preserved_stmt = self.st_line_id yield self.st_line_id = preserved_stmt self._ensure_loaded_lines() self.return_todo_command = {'done': True} def _action_to_check(self): """Validate and mark the transaction as needing review.""" self.st_line_id.move_id.checked = False self.invalidate_recordset(fnames=['st_line_checked']) self._action_validate() # ========================================================================= # JS ACTION HANDLERS # ========================================================================= def _js_action_mount_st_line(self, st_line_id): """Load a statement line by ID and return embedded view configuration.""" self.ensure_one() stmt_entry = self.env['account.bank.statement.line'].browse(st_line_id) self._action_mount_st_line(stmt_entry) self.return_todo_command = self._prepare_embedded_views_data() def _js_action_restore_st_line_data(self, initial_data): """Restore the widget to a previously saved state. Used when the user navigates back from an invoice form or other view. Checks if the liquidity entry was modified externally and re-triggers matching if so. """ self.ensure_one() saved_values = initial_data['initial_values'] self.st_line_id = self.env['account.bank.statement.line'].browse(saved_values['st_line_id']) saved_return_cmd = saved_values['return_todo_command'] # Detect liquidity line modifications requiring a full reload current_liq = self.line_ids.filtered(lambda rec: rec.flag == 'liquidity') saved_liq_data = next( (cmd[2] for cmd in saved_values['line_ids'] if cmd[2]['flag'] == 'liquidity'), {}, ) reference_liq = self.env['bank.rec.widget.line'].new(saved_liq_data) check_fields = saved_liq_data.keys() - {'index', 'suggestion_html'} for field_name in check_fields: if reference_liq[field_name] != current_liq[field_name]: self._js_action_mount_st_line(self.st_line_id.id) return # Remove fields that should be recomputed fresh for transient_field in ('id', 'st_line_id', 'todo_command', 'return_todo_command', 'available_reco_model_ids'): saved_values.pop(transient_field, None) matching_domain = self.st_line_id._get_default_amls_matching_domain() saved_values['line_ids'] = self._process_restore_lines_ids(saved_values['line_ids']) self.update(saved_values) # Check if a newly created invoice should be auto-matched if ( saved_return_cmd and saved_return_cmd.get('res_model') == 'account.move' and (new_doc := self.env['account.move'].browse(saved_return_cmd['res_id'])) and new_doc.state == 'posted' ): matchable_items = new_doc.line_ids.filtered_domain(matching_domain) self._action_add_new_amls(matchable_items) else: self._lines_add_auto_balance_line() self.return_todo_command = self._prepare_embedded_views_data() def _process_restore_lines_ids(self, saved_commands): """Filter saved line commands to remove entries whose source items are no longer available.""" matching_domain = self.st_line_id._get_default_amls_matching_domain() valid_source_ids = self.env['account.move.line'].browse( cmd[2]['source_aml_id'] for cmd in saved_commands if cmd[0] == Command.CREATE and cmd[2].get('source_aml_id') ).filtered_domain(matching_domain).ids valid_source_ids += [None] # Allow entries without a source restored_commands = [Command.clear()] for cmd in saved_commands: match cmd: case (Command.CREATE, _, vals) if vals.get('source_aml_id') in valid_source_ids: restored_commands.append(Command.create(vals)) case _: restored_commands.append(cmd) return restored_commands def _js_action_validate(self): """JS entry point for validation.""" with self._action_validate_method(): self._action_validate() def _js_action_to_check(self): """JS entry point for validate-and-flag-for-review.""" self.ensure_one() if self.state == 'valid': with self._action_validate_method(): self._action_to_check() else: self.st_line_id.move_id.checked = False self.invalidate_recordset(fnames=['st_line_checked']) self.return_todo_command = {'done': True} def _js_action_reset(self): """Undo a completed reconciliation and return to matching mode. Validates that the transaction isn't locked by hash verification before proceeding with the un-reconciliation. """ self.ensure_one() stmt_entry = self.st_line_id if stmt_entry.inalterable_hash: if not stmt_entry.has_reconciled_entries: raise UserError(_( "You can't hit the reset button on a secured bank transaction." )) else: raise RedirectWarning( message=_( "This bank transaction is protected by an integrity hash and" " cannot be reset directly. Would you like to unreconcile it instead?" ), action=stmt_entry.move_id.open_reconcile_view(), button_text=_('View Reconciled Entries'), ) stmt_entry.action_undo_reconciliation() self.st_line_id = stmt_entry self._ensure_loaded_lines() self._action_trigger_matching_rules() self.return_todo_command = {'done': True} def _js_action_set_as_checked(self): """Mark the transaction as reviewed/checked.""" self.ensure_one() self.st_line_id.move_id.checked = True self.invalidate_recordset(fnames=['st_line_checked']) self.return_todo_command = {'done': True} def _js_action_remove_line(self, line_index): """Remove a specific widget entry by its index.""" self.ensure_one() target = self.line_ids.filtered(lambda rec: rec.index == line_index) self._action_remove_lines(target) def _js_action_add_new_aml(self, aml_id): """Add a single journal item as a reconciliation counterpart.""" self.ensure_one() move_line = self.env['account.move.line'].browse(aml_id) self._action_add_new_amls(move_line) def _js_action_remove_new_aml(self, aml_id): """Remove a specific matched journal item.""" self.ensure_one() move_line = self.env['account.move.line'].browse(aml_id) self._action_remove_new_amls(move_line) def _js_action_select_reconcile_model(self, reco_model_id): """Apply a reconciliation model by its ID.""" self.ensure_one() reco_model = self.env['account.reconcile.model'].browse(reco_model_id) self._action_select_reconcile_model(reco_model) def _js_action_mount_line_in_edit(self, line_index): """Select a widget entry for editing in the manual operations form.""" self.ensure_one() self.form_index = line_index def _js_action_line_changed(self, form_index, field_name): """Handle a field value change on a widget entry from the JS frontend. Invalidates the field cache to trigger recomputation of dependent fields, then dispatches to the appropriate _line_value_changed_* handler. """ self.ensure_one() target_entry = self.line_ids.filtered(lambda rec: rec.index == form_index) # Force recomputation by invalidating and re-setting the value current_value = target_entry[field_name] target_entry.invalidate_recordset(fnames=[field_name], flush=False) target_entry[field_name] = current_value handler = getattr(self, f'_line_value_changed_{field_name}') handler(target_entry) def _js_action_line_set_partner_receivable_account(self, form_index): """Switch the entry's account to the partner's receivable account.""" self.ensure_one() target_entry = self.line_ids.filtered(lambda rec: rec.index == form_index) target_entry.account_id = target_entry.partner_receivable_account_id self._line_value_changed_account_id(target_entry) def _js_action_line_set_partner_payable_account(self, form_index): """Switch the entry's account to the partner's payable account.""" self.ensure_one() target_entry = self.line_ids.filtered(lambda rec: rec.index == form_index) target_entry.account_id = target_entry.partner_payable_account_id self._line_value_changed_account_id(target_entry) def _js_action_redirect_to_move(self, form_index): """Open the source document (invoice, payment, or journal entry) in a new form.""" self.ensure_one() target_entry = self.line_ids.filtered(lambda rec: rec.index == form_index) source_move = target_entry.source_aml_move_id redirect_action = { 'type': 'ir.actions.act_window', 'context': {'create': False}, 'view_mode': 'form', } if source_move.origin_payment_id: redirect_action['res_model'] = 'account.payment' redirect_action['res_id'] = source_move.origin_payment_id.id else: redirect_action['res_model'] = 'account.move' redirect_action['res_id'] = source_move.id self.return_todo_command = clean_action(redirect_action, self.env) def _js_action_apply_line_suggestion(self, form_index): """Apply the computed suggestion amounts to a matched entry. Reads the suggestion values first to avoid dependency conflicts, then applies them and triggers the appropriate change handler. """ self.ensure_one() target_entry = self.line_ids.filtered(lambda rec: rec.index == form_index) # Capture suggestion values before modifying fields they depend on suggested_foreign = target_entry.suggestion_amount_currency suggested_comp = target_entry.suggestion_balance target_entry.amount_currency = suggested_foreign target_entry.balance = suggested_comp if target_entry.currency_id == target_entry.company_currency_id: self._line_value_changed_balance(target_entry) else: self._line_value_changed_amount_currency(target_entry) # ========================================================================= # GLOBAL INFO # ========================================================================= @api.model def collect_global_info_data(self, journal_id): """Retrieve the current statement balance for display in the widget header.""" journal = self.env['account.journal'].browse(journal_id) formatted_balance = '' if ( journal.exists() and any( company in journal.company_id._accessible_branches() for company in self.env.companies ) ): display_currency = journal.currency_id or journal.company_id.sudo().currency_id formatted_balance = formatLang( self.env, journal.current_statement_balance, currency_obj=display_currency, ) return {'balance_amount': formatted_balance}