Files
Odoo-Modules/Fusion Accounting/models/bank_rec_widget.py
2026-02-22 01:22:18 -05:00

2101 lines
89 KiB
Python

# 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('<ol>%s</ol>') % 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('<ol>%s</ol>') % children if children else ""
else:
rendered_value = data
if not rendered_value:
return ""
return markupsafe.Markup(
'<li style="list-style-type: none">'
'<span><span class="fw-bolder">%(label)s</span>%(content)s</span>'
'</li>'
) % {'label': label, 'content': rendered_value}
root_html = _build_html_node('', parsed)
return markupsafe.Markup("<ol>%s</ol>") % 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}