# Fusion Accounting - Manual Reconciliation Wizard # Handles partial/full reconciliation of selected journal items with # optional write-off, account transfers, and tax support. from collections import defaultdict from datetime import timedelta from odoo import api, Command, fields, models, _ from odoo.exceptions import UserError from odoo.tools import groupby, SQL from odoo.tools.misc import formatLang class AccountReconcileWizard(models.TransientModel): """Interactive wizard for reconciling selected journal items, optionally generating write-off or account transfer entries.""" _name = 'account.reconcile.wizard' _description = 'Account reconciliation wizard' _check_company_auto = True # ------------------------------------------------------------------------- # Default Values # ------------------------------------------------------------------------- @api.model def default_get(self, field_names): """Validate and load the selected journal items from the context.""" result = super().default_get(field_names) if 'move_line_ids' not in field_names: return result active_model = self.env.context.get('active_model') active_ids = self.env.context.get('active_ids') if active_model != 'account.move.line' or not active_ids: raise UserError(_('This wizard can only be used on journal items.')) selected_lines = self.env['account.move.line'].browse(active_ids) involved_accounts = selected_lines.account_id if len(involved_accounts) > 2: raise UserError(_( 'Reconciliation is limited to at most two accounts: %s', ', '.join(involved_accounts.mapped('display_name')), )) # When two accounts are involved, shadow the secondary account override_map = None if len(involved_accounts) == 2: primary_account = selected_lines[0].account_id override_map = { line: {'account_id': primary_account} for line in selected_lines if line.account_id != primary_account } selected_lines._check_amls_exigibility_for_reconciliation( shadowed_aml_values=override_map, ) result['move_line_ids'] = [Command.set(selected_lines.ids)] return result # ------------------------------------------------------------------------- # Field Declarations # ------------------------------------------------------------------------- company_id = fields.Many2one( comodel_name='res.company', required=True, readonly=True, compute='_compute_company_id', ) move_line_ids = fields.Many2many( comodel_name='account.move.line', string='Move lines to reconcile', required=True, ) reco_account_id = fields.Many2one( comodel_name='account.account', string='Reconcile Account', compute='_compute_reco_wizard_data', ) amount = fields.Monetary( string='Amount in company currency', currency_field='company_currency_id', compute='_compute_reco_wizard_data', ) company_currency_id = fields.Many2one( comodel_name='res.currency', string='Company currency', related='company_id.currency_id', ) amount_currency = fields.Monetary( string='Amount', currency_field='reco_currency_id', compute='_compute_reco_wizard_data', ) reco_currency_id = fields.Many2one( comodel_name='res.currency', string='Currency to use for reconciliation', compute='_compute_reco_wizard_data', ) edit_mode_amount = fields.Monetary( currency_field='company_currency_id', compute='_compute_edit_mode_amount', ) edit_mode_amount_currency = fields.Monetary( string='Edit mode amount', currency_field='edit_mode_reco_currency_id', compute='_compute_edit_mode_amount_currency', store=True, readonly=False, ) edit_mode_reco_currency_id = fields.Many2one( comodel_name='res.currency', compute='_compute_edit_mode_reco_currency', ) edit_mode = fields.Boolean( compute='_compute_edit_mode', ) single_currency_mode = fields.Boolean( compute='_compute_single_currency_mode', ) allow_partials = fields.Boolean( string="Allow partials", compute='_compute_allow_partials', store=True, readonly=False, ) force_partials = fields.Boolean( compute='_compute_reco_wizard_data', ) display_allow_partials = fields.Boolean( compute='_compute_display_allow_partials', ) date = fields.Date( string='Date', compute='_compute_date', store=True, readonly=False, ) journal_id = fields.Many2one( comodel_name='account.journal', string='Journal', check_company=True, domain="[('type', '=', 'general')]", compute='_compute_journal_id', store=True, readonly=False, required=True, precompute=True, ) account_id = fields.Many2one( comodel_name='account.account', string='Account', check_company=True, domain="[('account_type', '!=', 'off_balance')]", ) is_rec_pay_account = fields.Boolean( compute='_compute_is_rec_pay_account', ) to_partner_id = fields.Many2one( comodel_name='res.partner', string='Partner', check_company=True, compute='_compute_to_partner_id', store=True, readonly=False, ) label = fields.Char(string='Label', default='Write-Off') tax_id = fields.Many2one( comodel_name='account.tax', string='Tax', default=False, check_company=True, ) to_check = fields.Boolean( string='To Check', default=False, help='Flag this entry for review if information is uncertain.', ) is_write_off_required = fields.Boolean( string='Is a write-off move required to reconcile', compute='_compute_is_write_off_required', ) is_transfer_required = fields.Boolean( string='Is an account transfer required', compute='_compute_reco_wizard_data', ) transfer_warning_message = fields.Char( string='Transfer warning message', compute='_compute_reco_wizard_data', ) transfer_from_account_id = fields.Many2one( comodel_name='account.account', string='Account Transfer From', compute='_compute_reco_wizard_data', ) lock_date_violated_warning_message = fields.Char( string='Lock date violation warning', compute='_compute_lock_date_violated_warning_message', ) reco_model_id = fields.Many2one( comodel_name='account.reconcile.model', string='Reconciliation model', store=False, check_company=True, ) reco_model_autocomplete_ids = fields.Many2many( comodel_name='account.reconcile.model', string='All reconciliation models', compute='_compute_reco_model_autocomplete_ids', ) # ------------------------------------------------------------------------- # Compute Methods # ------------------------------------------------------------------------- @api.depends('move_line_ids.company_id') def _compute_company_id(self): for wiz in self: wiz.company_id = wiz.move_line_ids[0].company_id @api.depends('move_line_ids') def _compute_single_currency_mode(self): for wiz in self: foreign_currencies = wiz.move_line_ids.currency_id - wiz.company_currency_id wiz.single_currency_mode = len(foreign_currencies) <= 1 @api.depends('force_partials') def _compute_allow_partials(self): for wiz in self: wiz.allow_partials = wiz.display_allow_partials and wiz.force_partials @api.depends('move_line_ids') def _compute_display_allow_partials(self): """Show the partial reconciliation checkbox only when both debit and credit items are selected.""" for wiz in self: found_debit = found_credit = False for line in wiz.move_line_ids: if line.balance > 0.0 or line.amount_currency > 0.0: found_debit = True elif line.balance < 0.0 or line.amount_currency < 0.0: found_credit = True if found_debit and found_credit: break wiz.display_allow_partials = found_debit and found_credit @api.depends('move_line_ids', 'journal_id', 'tax_id') def _compute_date(self): for wiz in self: latest_date = max(ln.date for ln in wiz.move_line_ids) temp_entry = self.env['account.move'].new({ 'journal_id': wiz.journal_id.id, }) wiz.date = temp_entry._get_accounting_date( latest_date, bool(wiz.tax_id), ) @api.depends('company_id') def _compute_journal_id(self): for wiz in self: wiz.journal_id = self.env['account.journal'].search([ *self.env['account.journal']._check_company_domain(wiz.company_id), ('type', '=', 'general'), ], limit=1) @api.depends('account_id') def _compute_is_rec_pay_account(self): for wiz in self: wiz.is_rec_pay_account = ( wiz.account_id.account_type in ('asset_receivable', 'liability_payable') ) @api.depends('is_rec_pay_account') def _compute_to_partner_id(self): for wiz in self: if wiz.is_rec_pay_account: linked_partners = wiz.move_line_ids.partner_id wiz.to_partner_id = linked_partners if len(linked_partners) == 1 else None else: wiz.to_partner_id = None @api.depends('amount', 'amount_currency') def _compute_is_write_off_required(self): """A write-off is needed when the balance does not reach zero.""" for wiz in self: company_zero = wiz.company_currency_id.is_zero(wiz.amount) reco_zero = ( wiz.reco_currency_id.is_zero(wiz.amount_currency) if wiz.reco_currency_id else True ) wiz.is_write_off_required = not company_zero or not reco_zero @api.depends('move_line_ids') def _compute_reco_wizard_data(self): """Compute reconciliation currency, transfer requirements, and write-off amounts from the selected journal items.""" def _determine_transfer_info(lines, accts): """When two accounts are involved, decide which one to transfer from.""" balance_per_acct = defaultdict(float) for ln in lines: balance_per_acct[ln.account_id] += ln.amount_residual # Transfer from the account with the smaller absolute balance if abs(balance_per_acct[accts[0]]) < abs(balance_per_acct[accts[1]]): src_acct, dst_acct = accts[0], accts[1] else: src_acct, dst_acct = accts[1], accts[0] transfer_lines = lines.filtered(lambda ln: ln.account_id == src_acct) foreign_curs = lines.currency_id - lines.company_currency_id if len(foreign_curs) == 1: xfer_currency = foreign_curs xfer_amount = sum(ln.amount_currency for ln in transfer_lines) else: xfer_currency = lines.company_currency_id xfer_amount = sum(ln.balance for ln in transfer_lines) if xfer_amount == 0.0 and xfer_currency != lines.company_currency_id: xfer_currency = lines.company_currency_id xfer_amount = sum(ln.balance for ln in transfer_lines) formatted_amt = formatLang( self.env, abs(xfer_amount), currency_obj=xfer_currency, ) warning_msg = _( 'An entry will transfer %(amount)s from %(from_account)s to %(to_account)s.', amount=formatted_amt, from_account=(src_acct.display_name if xfer_amount < 0 else dst_acct.display_name), to_account=(dst_acct.display_name if xfer_amount < 0 else src_acct.display_name), ) return { 'transfer_from_account_id': src_acct, 'reco_account_id': dst_acct, 'transfer_warning_message': warning_msg, } def _resolve_reco_currency(lines, residual_map): """Determine the best currency for reconciliation.""" base_cur = lines.company_currency_id foreign_curs = lines.currency_id - base_cur if not foreign_curs: return base_cur if len(foreign_curs) == 1: return foreign_curs # Multiple foreign currencies - check which have residuals lines_with_balance = self.env['account.move.line'] for ln, vals in residual_map.items(): if vals['amount_residual'] or vals['amount_residual_currency']: lines_with_balance += ln if lines_with_balance and len(lines_with_balance.currency_id - base_cur) > 1: return False return (lines_with_balance.currency_id - base_cur) or base_cur for wiz in self: amls = wiz.move_line_ids._origin accts = amls.account_id # Reset defaults wiz.reco_currency_id = False wiz.amount_currency = wiz.amount = 0.0 wiz.force_partials = True wiz.transfer_from_account_id = wiz.transfer_warning_message = False wiz.is_transfer_required = len(accts) == 2 if wiz.is_transfer_required: wiz.update(_determine_transfer_info(amls, accts)) else: wiz.reco_account_id = accts # Shadow all items to the reconciliation account shadow_vals = { ln: {'account_id': wiz.reco_account_id} for ln in amls } # Build reconciliation plan plan_list, all_lines = amls._optimize_reconciliation_plan( [amls], shadowed_aml_values=shadow_vals, ) # Prefetch for performance all_lines.move_id all_lines.matched_debit_ids all_lines.matched_credit_ids residual_map = { ln: { 'aml': ln, 'amount_residual': ln.amount_residual, 'amount_residual_currency': ln.amount_residual_currency, } for ln in all_lines } skip_exchange_diff = bool( self.env['ir.config_parameter'].sudo().get_param( 'account.disable_partial_exchange_diff', ) ) plan = plan_list[0] amls.with_context( no_exchange_difference=( self.env.context.get('no_exchange_difference') or skip_exchange_diff ), )._prepare_reconciliation_plan( plan, residual_map, shadowed_aml_values=shadow_vals, ) resolved_cur = _resolve_reco_currency(amls, residual_map) if not resolved_cur: continue residual_amounts = { ln: ln._prepare_move_line_residual_amounts( vals, resolved_cur, shadowed_aml_values=shadow_vals, ) for ln, vals in residual_map.items() } # Verify all residuals are expressed in the resolved currency if all( resolved_cur in rv for rv in residual_amounts.values() if rv ): wiz.reco_currency_id = resolved_cur elif all( amls.company_currency_id in rv for rv in residual_amounts.values() if rv ): wiz.reco_currency_id = amls.company_currency_id resolved_cur = wiz.reco_currency_id else: continue # Compute write-off amounts using the most recent line's rate newest_line = max(amls, key=lambda ln: ln.date) if not newest_line.amount_currency: fx_rate = lower_bound = upper_bound = 0.0 elif newest_line.currency_id == resolved_cur: fx_rate = abs(newest_line.balance / newest_line.amount_currency) tolerance = ( amls.company_currency_id.rounding / 2 / abs(newest_line.amount_currency) ) lower_bound = fx_rate - tolerance upper_bound = fx_rate + tolerance else: fx_rate = self.env['res.currency']._get_conversion_rate( resolved_cur, amls.company_currency_id, amls.company_id, newest_line.date, ) lower_bound = upper_bound = fx_rate # Identify lines at the correct rate to avoid spurious exchange diffs at_correct_rate = { ln for ln, rv in residual_amounts.items() if ( ln.currency_id == resolved_cur and abs(ln.balance) >= ln.company_currency_id.round( abs(ln.amount_currency) * lower_bound ) and abs(ln.balance) <= ln.company_currency_id.round( abs(ln.amount_currency) * upper_bound ) ) } wiz.amount_currency = sum( rv[wiz.reco_currency_id]['residual'] for rv in residual_amounts.values() if rv ) raw_amount = sum( ( rv[amls.company_currency_id]['residual'] if ln in at_correct_rate else rv[wiz.reco_currency_id]['residual'] * fx_rate ) for ln, rv in residual_amounts.items() if rv ) wiz.amount = amls.company_currency_id.round(raw_amount) wiz.force_partials = False @api.depends('move_line_ids') def _compute_edit_mode_amount_currency(self): for wiz in self: wiz.edit_mode_amount_currency = ( wiz.amount_currency if wiz.edit_mode else 0.0 ) @api.depends('edit_mode_amount_currency') def _compute_edit_mode_amount(self): for wiz in self: if wiz.edit_mode: single_ln = wiz.move_line_ids conversion = ( abs(single_ln.amount_currency / single_ln.balance) if single_ln.balance else 0.0 ) wiz.edit_mode_amount = ( single_ln.company_currency_id.round( wiz.edit_mode_amount_currency / conversion ) if conversion else 0.0 ) else: wiz.edit_mode_amount = 0.0 @api.depends('move_line_ids') def _compute_edit_mode_reco_currency(self): for wiz in self: wiz.edit_mode_reco_currency_id = ( wiz.move_line_ids.currency_id if wiz.edit_mode else False ) @api.depends('move_line_ids') def _compute_edit_mode(self): for wiz in self: wiz.edit_mode = len(wiz.move_line_ids) == 1 @api.depends('move_line_ids.move_id', 'date') def _compute_lock_date_violated_warning_message(self): for wiz in self: override_date = wiz._get_date_after_lock_date() if override_date: wiz.lock_date_violated_warning_message = _( 'The selected date conflicts with a lock date. ' 'It will be adjusted to: %(replacement_date)s', replacement_date=override_date, ) else: wiz.lock_date_violated_warning_message = None @api.depends('company_id') def _compute_reco_model_autocomplete_ids(self): """Find reconciliation models of type write-off with exactly one line.""" for wiz in self: filter_domain = [ ('rule_type', '=', 'writeoff_button'), ('company_id', '=', wiz.company_id.id), ('counterpart_type', 'not in', ('sale', 'purchase')), ] q = self.env['account.reconcile.model']._where_calc(filter_domain) matching_ids = [ row[0] for row in self.env.execute_query(SQL(""" SELECT arm.id FROM %s JOIN account_reconcile_model_line arml ON arml.model_id = arm.id WHERE %s GROUP BY arm.id HAVING COUNT(arm.id) = 1 """, q.from_clause, q.where_clause or SQL("TRUE"))) ] wiz.reco_model_autocomplete_ids = ( self.env['account.reconcile.model'].browse(matching_ids) ) # ------------------------------------------------------------------------- # Onchange # ------------------------------------------------------------------------- @api.onchange('reco_model_id') def _onchange_reco_model_id(self): """Pre-fill write-off details from the selected reconciliation model.""" if self.reco_model_id: model_line = self.reco_model_id.line_ids self.to_check = self.reco_model_id.to_check self.label = model_line.label self.tax_id = model_line.tax_ids[0] if model_line[0].tax_ids else None self.journal_id = model_line.journal_id self.account_id = model_line.account_id # ------------------------------------------------------------------------- # Constraints # ------------------------------------------------------------------------- @api.constrains('edit_mode_amount_currency') def _check_min_max_edit_mode_amount_currency(self): for wiz in self: if not wiz.edit_mode: continue if wiz.edit_mode_amount_currency == 0.0: raise UserError(_( 'The write-off amount for a single line cannot be zero.' )) is_debit = ( wiz.move_line_ids.balance > 0.0 or wiz.move_line_ids.amount_currency > 0.0 ) if is_debit and wiz.edit_mode_amount_currency < 0.0: raise UserError(_( 'The write-off amount for a debit line must be positive.' )) if not is_debit and wiz.edit_mode_amount_currency > 0.0: raise UserError(_( 'The write-off amount for a credit line must be negative.' )) # ------------------------------------------------------------------------- # Actions # ------------------------------------------------------------------------- def _action_open_wizard(self): self.ensure_one() return { 'name': _('Write-Off Entry'), 'type': 'ir.actions.act_window', 'view_type': 'form', 'view_mode': 'form', 'res_model': 'account.reconcile.wizard', 'target': 'new', } # ------------------------------------------------------------------------- # Business Logic # ------------------------------------------------------------------------- def _get_date_after_lock_date(self): """Return the first valid date if the current date violates a lock.""" self.ensure_one() violated = self.company_id._get_violated_lock_dates( self.date, bool(self.tax_id), self.journal_id, ) if violated: return violated[-1][0] + timedelta(days=1) def _compute_write_off_taxes_data(self, partner): """Calculate tax breakdown for the write-off entry, including base and per-repartition-line tax amounts.""" TaxEngine = self.env['account.tax'] wo_amount_currency = self.edit_mode_amount_currency or self.amount_currency wo_amount = self.edit_mode_amount or self.amount conversion_rate = abs(wo_amount_currency / wo_amount) tax_usage = self.tax_id.type_tax_use if self.tax_id else None is_refund_entry = ( (tax_usage == 'sale' and wo_amount_currency > 0.0) or (tax_usage == 'purchase' and wo_amount_currency < 0.0) ) base_input = TaxEngine._prepare_base_line_for_taxes_computation( self, partner_id=partner, currency_id=self.reco_currency_id, tax_ids=self.tax_id, price_unit=wo_amount_currency, quantity=1.0, account_id=self.account_id, is_refund=is_refund_entry, rate=conversion_rate, special_mode='total_included', ) computation_lines = [base_input] TaxEngine._add_tax_details_in_base_lines(computation_lines, self.company_id) TaxEngine._round_base_lines_tax_details(computation_lines, self.company_id) TaxEngine._add_accounting_data_in_base_lines_tax_details( computation_lines, self.company_id, include_caba_tags=True, ) tax_output = TaxEngine._prepare_tax_lines(computation_lines, self.company_id) _input_line, base_update = tax_output['base_lines_to_update'][0] tax_detail_list = [] for tax_line_vals in tax_output['tax_lines_to_add']: tax_detail_list.append({ 'tax_amount': tax_line_vals['balance'], 'tax_amount_currency': tax_line_vals['amount_currency'], 'tax_tag_ids': tax_line_vals['tax_tag_ids'], 'tax_account_id': tax_line_vals['account_id'], }) base_amt_currency = base_update['amount_currency'] base_amt = wo_amount - sum(d['tax_amount'] for d in tax_detail_list) return { 'base_amount': base_amt, 'base_amount_currency': base_amt_currency, 'base_tax_tag_ids': base_update['tax_tag_ids'], 'tax_lines_data': tax_detail_list, } def _create_write_off_lines(self, partner=None): """Build Command.create entries for the write-off journal entry.""" if not partner: partner = self.env['res.partner'] target_partner = self.to_partner_id if self.is_rec_pay_account else partner tax_info = ( self._compute_write_off_taxes_data(target_partner) if self.tax_id else None ) wo_amount_currency = self.edit_mode_amount_currency or self.amount_currency wo_amount = self.edit_mode_amount or self.amount line_commands = [ # Counterpart on reconciliation account Command.create({ 'name': self.label or _('Write-Off'), 'account_id': self.reco_account_id.id, 'partner_id': partner.id, 'currency_id': self.reco_currency_id.id, 'amount_currency': -wo_amount_currency, 'balance': -wo_amount, }), # Write-off on target account Command.create({ 'name': self.label, 'account_id': self.account_id.id, 'partner_id': target_partner.id, 'currency_id': self.reco_currency_id.id, 'tax_ids': self.tax_id.ids, 'tax_tag_ids': ( tax_info['base_tax_tag_ids'] if tax_info else None ), 'amount_currency': ( tax_info['base_amount_currency'] if tax_info else wo_amount_currency ), 'balance': ( tax_info['base_amount'] if tax_info else wo_amount ), }), ] # Append one line per tax repartition if tax_info: for detail in tax_info['tax_lines_data']: line_commands.append(Command.create({ 'name': self.tax_id.name, 'account_id': detail['tax_account_id'], 'partner_id': target_partner.id, 'currency_id': self.reco_currency_id.id, 'tax_tag_ids': detail['tax_tag_ids'], 'amount_currency': detail['tax_amount_currency'], 'balance': detail['tax_amount'], })) return line_commands def create_write_off(self): """Create and post a write-off journal entry.""" self.ensure_one() linked_partners = self.move_line_ids.partner_id partner = linked_partners if len(linked_partners) == 1 else None entry_vals = { 'journal_id': self.journal_id.id, 'company_id': self.company_id.id, 'date': self._get_date_after_lock_date() or self.date, 'checked': not self.to_check, 'line_ids': self._create_write_off_lines(partner=partner), } wo_move = self.env['account.move'].with_context( skip_invoice_sync=True, skip_invoice_line_sync=True, ).create(entry_vals) wo_move.action_post() return wo_move def create_transfer(self): """Create and post an account transfer entry, grouped by partner and currency to maintain correct partner ledger balances.""" self.ensure_one() transfer_cmds = [] source_lines = self.move_line_ids.filtered( lambda ln: ln.account_id == self.transfer_from_account_id, ) for (partner, currency), grouped_lines in groupby( source_lines, lambda ln: (ln.partner_id, ln.currency_id), ): group_balance = sum(ln.amount_residual for ln in grouped_lines) group_amt_cur = sum(ln.amount_residual_currency for ln in grouped_lines) transfer_cmds.extend([ Command.create({ 'name': _('Transfer from %s', self.transfer_from_account_id.display_name), 'account_id': self.reco_account_id.id, 'partner_id': partner.id, 'currency_id': currency.id, 'amount_currency': group_amt_cur, 'balance': group_balance, }), Command.create({ 'name': _('Transfer to %s', self.reco_account_id.display_name), 'account_id': self.transfer_from_account_id.id, 'partner_id': partner.id, 'currency_id': currency.id, 'amount_currency': -group_amt_cur, 'balance': -group_balance, }), ]) xfer_move = self.env['account.move'].create({ 'journal_id': self.journal_id.id, 'company_id': self.company_id.id, 'date': self._get_date_after_lock_date() or self.date, 'line_ids': transfer_cmds, }) xfer_move.action_post() return xfer_move def reconcile(self): """Execute reconciliation with optional transfer and/or write-off.""" self.ensure_one() target_lines = self.move_line_ids._origin needs_transfer = self.is_transfer_required needs_writeoff = ( self.edit_mode or (self.is_write_off_required and not self.allow_partials) ) # Handle account transfer if two accounts are involved if needs_transfer: xfer_move = self.create_transfer() from_lines = target_lines.filtered( lambda ln: ln.account_id == self.transfer_from_account_id, ) xfer_from_lines = xfer_move.line_ids.filtered( lambda ln: ln.account_id == self.transfer_from_account_id, ) xfer_to_lines = xfer_move.line_ids.filtered( lambda ln: ln.account_id == self.reco_account_id, ) (from_lines + xfer_from_lines).reconcile() target_lines = target_lines - from_lines + xfer_to_lines # Handle write-off if balance is non-zero if needs_writeoff: wo_move = self.create_write_off() wo_counterpart = wo_move.line_ids[0] target_lines += wo_counterpart reconcile_plan = [[target_lines, wo_counterpart]] else: reconcile_plan = [target_lines] self.env['account.move.line']._reconcile_plan(reconcile_plan) if needs_transfer: return target_lines + xfer_move.line_ids return target_lines def reconcile_open(self): """Reconcile and open the result in the reconciliation view.""" self.ensure_one() return self.reconcile().open_reconcile_view()