# Fusion Accounting - Multicurrency Revaluation Wizard # Creates journal entries to adjust for exchange rate differences # on foreign-currency denominated balances, with automatic reversal. import json from dateutil.relativedelta import relativedelta from odoo import api, fields, models, _, Command from odoo.exceptions import UserError from odoo.tools import format_date class MulticurrencyRevaluationWizard(models.TransientModel): """Generates revaluation journal entries that capture unrealized gains or losses from exchange rate fluctuations, then creates an auto-reversal entry for the following period.""" _name = 'account.multicurrency.revaluation.wizard' _description = 'Multicurrency Revaluation Wizard' # --- Organization --- company_id = fields.Many2one( comodel_name='res.company', default=lambda self: self.env.company, ) # --- Accounting Configuration --- journal_id = fields.Many2one( comodel_name='account.journal', compute='_compute_accounting_values', inverse='_inverse_revaluation_journal', compute_sudo=True, domain=[('type', '=', 'general')], required=True, readonly=False, ) date = fields.Date( default=lambda self: self.env.context[ 'multicurrency_revaluation_report_options' ]['date']['date_to'], required=True, ) reversal_date = fields.Date(required=True) expense_provision_account_id = fields.Many2one( comodel_name='account.account', compute='_compute_accounting_values', inverse='_inverse_expense_provision_account', compute_sudo=True, string="Expense Account", required=True, readonly=False, ) income_provision_account_id = fields.Many2one( comodel_name='account.account', compute='_compute_accounting_values', inverse='_inverse_income_provision_account', compute_sudo=True, string="Income Account", required=True, readonly=False, ) # --- Preview & Warnings --- preview_data = fields.Text( compute='_compute_preview_data', ) show_warning_move_id = fields.Many2one( comodel_name='account.move', compute='_compute_show_warning', ) # ------------------------------------------------------------------------- # Defaults # ------------------------------------------------------------------------- @api.model def default_get(self, default_fields): """Initialize reversal date and validate that adjustments are needed.""" result = super().default_get(default_fields) if 'reversal_date' in default_fields: report_opts = self.env.context['multicurrency_revaluation_report_options'] period_end = fields.Date.to_date(report_opts['date']['date_to']) result['reversal_date'] = period_end + relativedelta(days=1) # Verify there is actually something to adjust if ( not self.env.context.get('revaluation_no_loop') and not self.with_context( revaluation_no_loop=True, )._get_move_vals()['line_ids'] ): raise UserError(_("No currency adjustment is needed.")) return result # ------------------------------------------------------------------------- # Compute Methods # ------------------------------------------------------------------------- @api.depends('expense_provision_account_id', 'income_provision_account_id', 'reversal_date') def _compute_show_warning(self): """Warn if there's an unreversed entry on the provision accounts.""" AML = self.env['account.move.line'] for wiz in self: provision_accts = ( wiz.expense_provision_account_id + wiz.income_provision_account_id ) recent_entry = AML.search([ ('account_id', 'in', provision_accts.ids), ('date', '<', wiz.reversal_date), ], order='date desc', limit=1).move_id wiz.show_warning_move_id = ( False if recent_entry.reversed_entry_id else recent_entry ) @api.depends('expense_provision_account_id', 'income_provision_account_id', 'date', 'journal_id') def _compute_preview_data(self): """Build JSON data for the move preview widget.""" col_definitions = [ {'field': 'account_id', 'label': _("Account")}, {'field': 'name', 'label': _("Label")}, {'field': 'debit', 'label': _("Debit"), 'class': 'text-end text-nowrap'}, {'field': 'credit', 'label': _("Credit"), 'class': 'text-end text-nowrap'}, ] for wiz in self: move_vals = self._get_move_vals() preview_groups = [ self.env['account.move']._move_dict_to_preview_vals( move_vals, wiz.company_id.currency_id, ), ] wiz.preview_data = json.dumps({ 'groups_vals': preview_groups, 'options': {'columns': col_definitions}, }) @api.depends('company_id') def _compute_accounting_values(self): """Load revaluation settings from the company.""" for wiz in self: wiz.journal_id = wiz.company_id.account_revaluation_journal_id wiz.expense_provision_account_id = ( wiz.company_id.account_revaluation_expense_provision_account_id ) wiz.income_provision_account_id = ( wiz.company_id.account_revaluation_income_provision_account_id ) # ------------------------------------------------------------------------- # Inverse Methods (persist settings back to company) # ------------------------------------------------------------------------- def _inverse_revaluation_journal(self): for wiz in self: wiz.company_id.sudo().account_revaluation_journal_id = wiz.journal_id def _inverse_expense_provision_account(self): for wiz in self: wiz.company_id.sudo().account_revaluation_expense_provision_account_id = ( wiz.expense_provision_account_id ) def _inverse_income_provision_account(self): for wiz in self: wiz.company_id.sudo().account_revaluation_income_provision_account_id = ( wiz.income_provision_account_id ) # ------------------------------------------------------------------------- # Move Value Construction # ------------------------------------------------------------------------- @api.model def _get_move_vals(self): """Build the journal entry values from the multicurrency revaluation report data, creating a pair of lines per account/currency that needs adjustment.""" def _extract_model_id(parsed_segments, target_model): """Extract the record ID for a given model from parsed line segments.""" for _dummy, seg_model, seg_id in parsed_segments: if seg_model == target_model: return seg_id return None def _extract_adjustment(report_line): """Pull the adjustment amount from the report line columns.""" for col in report_line.get('columns', []): if col.get('expression_label') == 'adjustment': return col.get('no_format') return None report = self.env.ref('fusion_accounting.multicurrency_revaluation_report') included_section = report.line_ids.filtered( lambda ln: ln.code == 'multicurrency_included', ) included_line_id = report._get_generic_line_id( 'account.report.line', included_section.id, ) report_options = { **self.env.context['multicurrency_revaluation_report_options'], 'unfold_all': False, } all_report_lines = report._get_lines(report_options) entry_lines = [] for rpt_line in report._get_unfolded_lines( all_report_lines, included_line_id, ): parsed = report._parse_line_id(rpt_line.get('id')) adj_balance = _extract_adjustment(rpt_line) # Only process account-level lines with non-zero adjustments if parsed[-1][-2] != 'account.account': continue if self.env.company.currency_id.is_zero(adj_balance): continue target_account = _extract_model_id(parsed, 'account.account') target_currency = _extract_model_id(parsed, 'res.currency') currency_name = self.env['res.currency'].browse( target_currency, ).display_name company_cur_name = self.env.company.currency_id.display_name current_rate = report_options['currency_rates'][ str(target_currency) ]['rate'] # Account adjustment line entry_lines.append(Command.create({ 'name': _( "Provision for %(for_cur)s " "(1 %(comp_cur)s = %(rate)s %(for_cur)s)", for_cur=currency_name, comp_cur=company_cur_name, rate=current_rate, ), 'debit': adj_balance if adj_balance > 0 else 0, 'credit': -adj_balance if adj_balance < 0 else 0, 'amount_currency': 0, 'currency_id': target_currency, 'account_id': target_account, })) # Provision counterpart line if adj_balance < 0: provision_label = _( "Expense Provision for %s", currency_name, ) provision_account = self.expense_provision_account_id.id else: provision_label = _( "Income Provision for %s", currency_name, ) provision_account = self.income_provision_account_id.id entry_lines.append(Command.create({ 'name': provision_label, 'debit': -adj_balance if adj_balance < 0 else 0, 'credit': adj_balance if adj_balance > 0 else 0, 'amount_currency': 0, 'currency_id': target_currency, 'account_id': provision_account, })) return { 'ref': _( "Foreign currency adjustment as of %s", format_date(self.env, self.date), ), 'journal_id': self.journal_id.id, 'date': self.date, 'line_ids': entry_lines, } # ------------------------------------------------------------------------- # Main Action # ------------------------------------------------------------------------- def create_entries(self): """Create the revaluation entry and its automatic reversal.""" self.ensure_one() move_data = self._get_move_vals() if not move_data['line_ids']: raise UserError(_("No provision adjustment is required.")) # Create and post the revaluation entry reval_move = self.env['account.move'].create(move_data) reval_move.action_post() # Create and post the reversal reversal = reval_move._reverse_moves(default_values_list=[{ 'ref': _("Reversal of: %s", reval_move.ref), }]) reversal.date = self.reversal_date reversal.action_post() # Open the revaluation entry in form view form_view = self.env.ref('account.view_move_form', False) clean_ctx = { k: v for k, v in self.env.context.items() if k != 'id' } return { 'type': 'ir.actions.act_window', 'res_model': 'account.move', 'res_id': reval_move.id, 'view_mode': 'form', 'view_id': form_view.id, 'views': [(form_view.id, 'form')], 'context': clean_ctx, }