# Fusion Accounting - Bank Reconciliation Widget Line # Original implementation for Fusion Accounting module import uuid import markupsafe from odoo import _, api, fields, models, Command from odoo.osv import expression from odoo.tools.misc import formatLang, frozendict # Flags that derive their values directly from the source journal entry _SOURCE_LINKED_FLAGS = frozenset({'aml', 'new_aml', 'liquidity', 'exchange_diff'}) # Flags where the statement line date should be used _STMT_DATE_FLAGS = frozenset({'liquidity', 'auto_balance', 'manual', 'early_payment', 'tax_line'}) # Flags that derive partner from the source journal entry _PARTNER_FROM_SOURCE_FLAGS = frozenset({'aml', 'new_aml'}) # Flags that use the widget's partner _PARTNER_FROM_WIDGET_FLAGS = frozenset({'liquidity', 'auto_balance', 'manual', 'early_payment', 'tax_line'}) # Flags that derive currency from the transaction _TRANSACTION_CURRENCY_FLAGS = frozenset({'auto_balance', 'manual', 'early_payment'}) class FusionBankRecLine(models.Model): """Represents a single entry within the bank reconciliation widget. Each entry has a 'flag' indicating its role in the reconciliation process: - liquidity: The bank/cash journal item from the statement line - new_aml: A journal item being matched against the statement line - aml: An already-reconciled journal item (read-only display) - exchange_diff: Automatically generated foreign exchange adjustment - tax_line: Tax amount computed from manual entries - manual: A user-created write-off or adjustment entry - early_payment: Discount entry for early payment terms - auto_balance: System-generated balancing entry This model exists only in memory; no database table is created. """ _name = "bank.rec.widget.line" _inherit = "analytic.mixin" _description = "Fusion bank reconciliation entry" _auto = False _table_query = "0" # --- Relationship to parent widget --- wizard_id = fields.Many2one(comodel_name='bank.rec.widget') index = fields.Char(compute='_compute_index') flag = fields.Selection( selection=[ ('liquidity', 'liquidity'), ('new_aml', 'new_aml'), ('aml', 'aml'), ('exchange_diff', 'exchange_diff'), ('tax_line', 'tax_line'), ('manual', 'manual'), ('early_payment', 'early_payment'), ('auto_balance', 'auto_balance'), ], ) # --- Core accounting fields --- journal_default_account_id = fields.Many2one( related='wizard_id.st_line_id.journal_id.default_account_id', depends=['wizard_id'], ) account_id = fields.Many2one( comodel_name='account.account', compute='_compute_account_id', store=True, readonly=False, check_company=True, domain="""[ ('id', '!=', journal_default_account_id), ('account_type', 'not in', ('asset_cash', 'off_balance')), ]""", ) date = fields.Date( compute='_compute_date', store=True, readonly=False, ) name = fields.Char( compute='_compute_name', store=True, readonly=False, ) partner_id = fields.Many2one( comodel_name='res.partner', compute='_compute_partner_id', store=True, readonly=False, ) currency_id = fields.Many2one( comodel_name='res.currency', compute='_compute_currency_id', store=True, readonly=False, ) company_id = fields.Many2one(related='wizard_id.company_id') country_code = fields.Char(related='company_id.country_id.code', depends=['company_id']) company_currency_id = fields.Many2one(related='wizard_id.company_currency_id') amount_currency = fields.Monetary( currency_field='currency_id', compute='_compute_amount_currency', store=True, readonly=False, ) balance = fields.Monetary( currency_field='company_currency_id', compute='_compute_balance', store=True, readonly=False, ) # --- Transaction currency fields (from statement line) --- transaction_currency_id = fields.Many2one( related='wizard_id.st_line_id.foreign_currency_id', depends=['wizard_id'], ) amount_transaction_currency = fields.Monetary( currency_field='transaction_currency_id', related='wizard_id.st_line_id.amount_currency', depends=['wizard_id'], ) # --- Debit/Credit split --- debit = fields.Monetary( currency_field='company_currency_id', compute='_compute_from_balance', ) credit = fields.Monetary( currency_field='company_currency_id', compute='_compute_from_balance', ) # --- Tax handling --- force_price_included_taxes = fields.Boolean() tax_base_amount_currency = fields.Monetary(currency_field='currency_id') # --- Source journal entry reference --- source_aml_id = fields.Many2one(comodel_name='account.move.line') source_aml_move_id = fields.Many2one( comodel_name='account.move', compute='_compute_source_aml_fields', store=True, readonly=False, ) source_aml_move_name = fields.Char( compute='_compute_source_aml_fields', store=True, readonly=False, ) # --- Tax detail fields --- tax_repartition_line_id = fields.Many2one( comodel_name='account.tax.repartition.line', compute='_compute_tax_repartition_line_id', store=True, readonly=False, ) tax_ids = fields.Many2many( comodel_name='account.tax', compute='_compute_tax_ids', store=True, readonly=False, check_company=True, ) tax_tag_ids = fields.Many2many( comodel_name='account.account.tag', compute='_compute_tax_tag_ids', store=True, readonly=False, ) group_tax_id = fields.Many2one( comodel_name='account.tax', compute='_compute_group_tax_id', store=True, readonly=False, ) # --- Reconcile model tracking --- reconcile_model_id = fields.Many2one(comodel_name='account.reconcile.model') # --- Original (pre-partial) amounts for comparison --- source_amount_currency = fields.Monetary(currency_field='currency_id') source_balance = fields.Monetary(currency_field='company_currency_id') source_debit = fields.Monetary( currency_field='company_currency_id', compute='_compute_from_source_balance', ) source_credit = fields.Monetary( currency_field='company_currency_id', compute='_compute_from_source_balance', ) # --- Visual indicators for partial amounts --- display_stroked_amount_currency = fields.Boolean(compute='_compute_display_stroked_amount_currency') display_stroked_balance = fields.Boolean(compute='_compute_display_stroked_balance') # --- Partner account info for UI suggestions --- partner_currency_id = fields.Many2one( comodel_name='res.currency', compute='_compute_partner_info', ) partner_receivable_account_id = fields.Many2one( comodel_name='account.account', compute='_compute_partner_info', ) partner_payable_account_id = fields.Many2one( comodel_name='account.account', compute='_compute_partner_info', ) partner_receivable_amount = fields.Monetary( currency_field='partner_currency_id', compute='_compute_partner_info', ) partner_payable_amount = fields.Monetary( currency_field='partner_currency_id', compute='_compute_partner_info', ) # --- Display fields --- bank_account = fields.Char(compute='_compute_bank_account') suggestion_html = fields.Html( compute='_compute_suggestion', sanitize=False, ) suggestion_amount_currency = fields.Monetary( currency_field='currency_id', compute='_compute_suggestion', ) suggestion_balance = fields.Monetary( currency_field='company_currency_id', compute='_compute_suggestion', ) ref = fields.Char( compute='_compute_ref_narration', store=True, readonly=False, ) narration = fields.Html( compute='_compute_ref_narration', store=True, readonly=False, ) manually_modified = fields.Boolean() # ========================================================================= # COMPUTE METHODS # ========================================================================= def _compute_index(self): """Assign a unique identifier to each entry for JS-side tracking.""" for entry in self: entry.index = uuid.uuid4() @api.depends('source_aml_id') def _compute_account_id(self): """Derive the account from the source journal item for linked entries. Entries tied to actual journal items (aml, new_aml, liquidity, exchange_diff) inherit the account directly. Other entry types retain their current account. """ for entry in self: if entry.flag in _SOURCE_LINKED_FLAGS: entry.account_id = entry.source_aml_id.account_id else: entry.account_id = entry.account_id @api.depends('source_aml_id') def _compute_date(self): """Set the date based on the entry type. Source-linked entries (aml, new_aml, exchange_diff) use the original journal item date. Statement-based entries use the statement line date. """ for entry in self: if entry.flag in _STMT_DATE_FLAGS: entry.date = entry.wizard_id.st_line_id.date elif entry.flag in ('aml', 'new_aml', 'exchange_diff'): entry.date = entry.source_aml_id.date else: entry.date = entry.date @api.depends('source_aml_id') def _compute_name(self): """Set the description/label from the source journal item when applicable. For entries derived from journal items, the label is taken from the original item. If the source has no name (e.g. credit notes), the move name is used as fallback. """ for entry in self: if entry.flag in ('aml', 'new_aml', 'liquidity'): entry.name = entry.source_aml_id.name or entry.source_aml_move_name else: entry.name = entry.name @api.depends('source_aml_id') def _compute_partner_id(self): """Determine the partner for each entry based on its type. Matched journal items carry their own partner. Statement-derived entries use the partner set on the reconciliation widget. """ for entry in self: if entry.flag in _PARTNER_FROM_SOURCE_FLAGS: entry.partner_id = entry.source_aml_id.partner_id elif entry.flag in _PARTNER_FROM_WIDGET_FLAGS: entry.partner_id = entry.wizard_id.partner_id else: entry.partner_id = entry.partner_id @api.depends('source_aml_id') def _compute_currency_id(self): """Set the currency based on entry type. Source-linked entries use the currency from the original journal item. Transaction-related entries (auto_balance, manual, early_payment) use the transaction currency from the bank statement. """ for entry in self: if entry.flag in _SOURCE_LINKED_FLAGS: entry.currency_id = entry.source_aml_id.currency_id elif entry.flag in _TRANSACTION_CURRENCY_FLAGS: entry.currency_id = entry.wizard_id.transaction_currency_id else: entry.currency_id = entry.currency_id @api.depends('source_aml_id') def _compute_balance(self): """Set the company-currency balance from the source when applicable. Only 'aml' and 'liquidity' entries copy the balance directly from the source journal item. All other types preserve their computed/manual balance. """ for entry in self: if entry.flag in ('aml', 'liquidity'): entry.balance = entry.source_aml_id.balance else: entry.balance = entry.balance @api.depends('source_aml_id') def _compute_amount_currency(self): """Set the foreign currency amount from the source when applicable. Only 'aml' and 'liquidity' entries copy directly from the source. """ for entry in self: if entry.flag in ('aml', 'liquidity'): entry.amount_currency = entry.source_aml_id.amount_currency else: entry.amount_currency = entry.amount_currency @api.depends('balance') def _compute_from_balance(self): """Split the balance into separate debit and credit components.""" for entry in self: entry.debit = max(entry.balance, 0.0) entry.credit = max(-entry.balance, 0.0) @api.depends('source_balance') def _compute_from_source_balance(self): """Split the original source balance into debit and credit.""" for entry in self: entry.source_debit = max(entry.source_balance, 0.0) entry.source_credit = max(-entry.source_balance, 0.0) @api.depends('source_aml_id', 'account_id', 'partner_id') def _compute_analytic_distribution(self): """Compute analytic distribution based on entry type. Source-linked entries (liquidity, aml) inherit from the source item. Tax/early-payment entries keep their current distribution. Other entries look up the default distribution from analytic distribution models. """ distribution_cache = {} for entry in self: if entry.flag in ('liquidity', 'aml'): entry.analytic_distribution = entry.source_aml_id.analytic_distribution elif entry.flag in ('tax_line', 'early_payment'): entry.analytic_distribution = entry.analytic_distribution else: lookup_params = frozendict({ "partner_id": entry.partner_id.id, "partner_category_id": entry.partner_id.category_id.ids, "account_prefix": entry.account_id.code, "company_id": entry.company_id.id, }) if lookup_params not in distribution_cache: distribution_cache[lookup_params] = ( self.env['account.analytic.distribution.model']._get_distribution(lookup_params) ) entry.analytic_distribution = distribution_cache[lookup_params] or entry.analytic_distribution @api.depends('source_aml_id') def _compute_tax_repartition_line_id(self): """Inherit tax repartition line from the source for 'aml' entries only.""" for entry in self: if entry.flag == 'aml': entry.tax_repartition_line_id = entry.source_aml_id.tax_repartition_line_id else: entry.tax_repartition_line_id = entry.tax_repartition_line_id @api.depends('source_aml_id') def _compute_tax_ids(self): """Copy applied tax references from the source for 'aml' entries.""" for entry in self: if entry.flag == 'aml': entry.tax_ids = [Command.set(entry.source_aml_id.tax_ids.ids)] else: entry.tax_ids = entry.tax_ids @api.depends('source_aml_id') def _compute_tax_tag_ids(self): """Copy tax tags from the source for 'aml' entries.""" for entry in self: if entry.flag == 'aml': entry.tax_tag_ids = [Command.set(entry.source_aml_id.tax_tag_ids.ids)] else: entry.tax_tag_ids = entry.tax_tag_ids @api.depends('source_aml_id') def _compute_group_tax_id(self): """Copy the group tax reference from the source for 'aml' entries.""" for entry in self: if entry.flag == 'aml': entry.group_tax_id = entry.source_aml_id.group_tax_id else: entry.group_tax_id = entry.group_tax_id @api.depends('currency_id', 'amount_currency', 'source_amount_currency') def _compute_display_stroked_amount_currency(self): """Determine whether to show a strikethrough on the foreign currency amount. This visual indicator appears when a 'new_aml' entry has been partially matched (its current amount differs from the original source amount). """ for entry in self: is_modified = entry.currency_id.compare_amounts( entry.amount_currency, entry.source_amount_currency ) != 0 entry.display_stroked_amount_currency = entry.flag == 'new_aml' and is_modified @api.depends('currency_id', 'balance', 'source_balance') def _compute_display_stroked_balance(self): """Determine whether to show a strikethrough on the balance. Applies to 'new_aml' and 'exchange_diff' entries whose balance has been adjusted from the original source value. """ for entry in self: balance_changed = entry.currency_id.compare_amounts( entry.balance, entry.source_balance ) != 0 entry.display_stroked_balance = ( entry.flag in ('new_aml', 'exchange_diff') and balance_changed ) @api.depends('flag') def _compute_source_aml_fields(self): """Resolve the originating move for display and navigation purposes. For 'new_aml' and 'liquidity' entries, this is simply the move containing the source journal item. For 'aml' (already reconciled) entries, we trace through partial reconciliation records to find the counterpart document. """ for entry in self: entry.source_aml_move_id = None entry.source_aml_move_name = None if entry.flag in ('new_aml', 'liquidity'): originating_move = entry.source_aml_id.move_id entry.source_aml_move_id = originating_move entry.source_aml_move_name = originating_move.name elif entry.flag == 'aml': # Trace through reconciliation partials to find the counterpart partial_records = ( entry.source_aml_id.matched_debit_ids + entry.source_aml_id.matched_credit_ids ) linked_items = partial_records.debit_move_id + partial_records.credit_move_id # Exclude the source itself and any exchange difference entries fx_move_items = partial_records.exchange_move_id.line_ids counterpart_items = linked_items - entry.source_aml_id - fx_move_items if len(counterpart_items) == 1: entry.source_aml_move_id = counterpart_items.move_id entry.source_aml_move_name = counterpart_items.move_id.name @api.depends('wizard_id.form_index', 'partner_id') def _compute_partner_info(self): """Load receivable/payable account info for the selected partner. This data is used by the UI to offer account switching suggestions when a partner is set on a manual entry. Only computed for the entry currently being edited (matching the form_index). """ for entry in self: # Set defaults entry.partner_receivable_amount = 0.0 entry.partner_payable_amount = 0.0 entry.partner_currency_id = None entry.partner_receivable_account_id = None entry.partner_payable_account_id = None # Only compute for the actively edited entry with a partner if not entry.partner_id or entry.index != entry.wizard_id.form_index: continue entry.partner_currency_id = entry.company_currency_id scoped_partner = entry.partner_id.with_company(entry.wizard_id.company_id) posted_filter = [('parent_state', '=', 'posted'), ('partner_id', '=', scoped_partner.id)] # Receivable info recv_account = scoped_partner.property_account_receivable_id entry.partner_receivable_account_id = recv_account if recv_account: recv_domain = expression.AND([posted_filter, [('account_id', '=', recv_account.id)]]) recv_data = self.env['account.move.line']._read_group( domain=recv_domain, aggregates=['amount_residual:sum'], ) entry.partner_receivable_amount = recv_data[0][0] # Payable info pay_account = scoped_partner.property_account_payable_id entry.partner_payable_account_id = pay_account if pay_account: pay_domain = expression.AND([posted_filter, [('account_id', '=', pay_account.id)]]) pay_data = self.env['account.move.line']._read_group( domain=pay_domain, aggregates=['amount_residual:sum'], ) entry.partner_payable_amount = pay_data[0][0] @api.depends('flag') def _compute_bank_account(self): """Show the bank account number on the liquidity entry only.""" for entry in self: if entry.flag == 'liquidity': stmt_line = entry.wizard_id.st_line_id displayed_account = stmt_line.partner_bank_id.display_name or stmt_line.account_number entry.bank_account = displayed_account or None else: entry.bank_account = None @api.depends('wizard_id.form_index', 'amount_currency', 'balance') def _compute_suggestion(self): """Build contextual suggestion text for matched journal items. When a 'new_aml' entry is being edited, this generates guidance text explaining the reconciliation impact and offering a quick action button for full or partial matching. """ for entry in self: entry.suggestion_html = None entry.suggestion_amount_currency = None entry.suggestion_balance = None # Only generate suggestions for actively edited matched entries if entry.flag != 'new_aml' or entry.index != entry.wizard_id.form_index: continue source_item = entry.source_aml_id parent_widget = entry.wizard_id original_residual = abs(source_item.amount_residual_currency) post_match_residual = abs(source_item.amount_residual_currency + entry.amount_currency) matched_portion = original_residual - post_match_residual fully_consumed = source_item.currency_id.is_zero(post_match_residual) belongs_to_invoice = source_item.move_id.is_invoice(include_receipts=True) # Build the clickable document reference doc_link_html = markupsafe.Markup( '' ) % {'doc_name': source_item.move_id.display_name} # Shared template parameters tpl_params = { 'amount': formatLang(self.env, matched_portion, currency_obj=source_item.currency_id), 'open_amount': formatLang(self.env, original_residual, currency_obj=source_item.currency_id), 'display_name_html': doc_link_html, 'btn_start': markupsafe.Markup( ''), } if fully_consumed: # Full match scenario if belongs_to_invoice: status_msg = _( "The invoice %(display_name_html)s with an open amount of" " %(open_amount)s will be entirely paid by the transaction." ) else: status_msg = _( "%(display_name_html)s with an open amount of %(open_amount)s" " will be fully reconciled by the transaction." ) suggestion_lines = [status_msg] # Check if a partial would be more appropriate partial_data = parent_widget._lines_check_partial_amount(entry) if partial_data: if belongs_to_invoice: partial_msg = _( "You might want to record a" " %(btn_start)spartial payment%(btn_end)s." ) else: partial_msg = _( "You might want to make a" " %(btn_start)spartial reconciliation%(btn_end)s instead." ) suggestion_lines.append(partial_msg) entry.suggestion_amount_currency = partial_data['amount_currency'] entry.suggestion_balance = partial_data['balance'] else: # Partial match scenario - suggest full reconciliation if belongs_to_invoice: suggestion_lines = [ _( "The invoice %(display_name_html)s with an open amount of" " %(open_amount)s will be reduced by %(amount)s." ), _( "You might want to set the invoice as" " %(btn_start)sfully paid%(btn_end)s." ), ] else: suggestion_lines = [ _( "%(display_name_html)s with an open amount of" " %(open_amount)s will be reduced by %(amount)s." ), _( "You might want to %(btn_start)sfully reconcile%(btn_end)s" " the document." ), ] entry.suggestion_amount_currency = entry.source_amount_currency entry.suggestion_balance = entry.source_balance rendered_lines = markupsafe.Markup('
').join( msg % tpl_params for msg in suggestion_lines ) entry.suggestion_html = ( markupsafe.Markup('
%s
') % rendered_lines ) @api.depends('flag') def _compute_ref_narration(self): """Populate ref and narration from the statement line for liquidity entries.""" for entry in self: if entry.flag == 'liquidity': entry.ref = entry.wizard_id.st_line_id.ref entry.narration = entry.wizard_id.st_line_id.narration else: entry.ref = None entry.narration = None # ========================================================================= # HELPERS # ========================================================================= def _get_aml_values(self, **kwargs): """Convert this widget entry into values suitable for creating journal items. Returns a dictionary of field values that can be passed to Command.create() for account.move.line records during validation. """ self.ensure_one() vals = { 'name': self.name, 'account_id': self.account_id.id, 'currency_id': self.currency_id.id, 'amount_currency': self.amount_currency, 'balance': self.debit - self.credit, 'reconcile_model_id': self.reconcile_model_id.id, 'analytic_distribution': self.analytic_distribution, 'tax_repartition_line_id': self.tax_repartition_line_id.id, 'tax_ids': [Command.set(self.tax_ids.ids)], 'tax_tag_ids': [Command.set(self.tax_tag_ids.ids)], 'group_tax_id': self.group_tax_id.id, } vals.update(kwargs) if self.flag == 'early_payment': vals['display_type'] = 'epd' return vals