""" Fusion Accounting - Invoice External Tax Integration ===================================================== Extends ``account.move`` to support external tax computation through the :class:`FusionExternalTaxProvider` framework. When enabled for an invoice, taxes are calculated by the configured external provider (e.g. AvaTax) instead of using Odoo's built-in tax engine. Key behaviours: * Before posting, the external tax provider is called to compute line-level taxes. The resulting tax amounts are written to dedicated tax lines on the invoice. * When an invoice is reset to draft, any previously committed external transactions are voided so they do not appear in tax filings. * A status widget on the invoice form indicates whether taxes have been computed externally and the associated document code. Copyright (c) Nexa Systems Inc. - All rights reserved. """ import logging from odoo import api, fields, models, Command, _ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class FusionMoveExternalTax(models.Model): """Adds external tax provider support to journal entries / invoices.""" _inherit = "account.move" # ------------------------------------------------------------------------- # Fields # ------------------------------------------------------------------------- fusion_is_tax_computed_externally = fields.Boolean( string="Tax Computed Externally", default=False, copy=False, help="Indicates that the tax amounts on this invoice were calculated " "by an external tax provider rather than Odoo's built-in engine.", ) fusion_tax_provider_id = fields.Many2one( comodel_name='fusion.external.tax.provider', string="External Tax Provider", copy=False, help="The external tax provider used to compute taxes on this invoice.", ) fusion_external_doc_code = fields.Char( string="External Document Code", copy=False, readonly=True, help="Reference code returned by the external tax provider. " "Used to void or adjust the transaction.", ) fusion_external_tax_amount = fields.Monetary( string="External Tax Amount", currency_field='currency_id', copy=False, readonly=True, help="Total tax amount as calculated by the external provider.", ) fusion_external_tax_date = fields.Datetime( string="Tax Computation Date", copy=False, readonly=True, help="Timestamp of the most recent external tax computation.", ) fusion_use_external_tax = fields.Boolean( string="Use External Tax Provider", compute='_compute_fusion_use_external_tax', store=False, help="Technical field: True when an external provider is active for " "this company and the move type supports external taxation.", ) # ------------------------------------------------------------------------- # Computed Fields # ------------------------------------------------------------------------- @api.depends('company_id', 'move_type') def _compute_fusion_use_external_tax(self): """Determine whether external tax computation is available.""" provider_model = self.env['fusion.external.tax.provider'] for move in self: provider = provider_model.get_provider(company=move.company_id) move.fusion_use_external_tax = bool(provider) and move.move_type in ( 'out_invoice', 'out_refund', 'in_invoice', 'in_refund', ) # ------------------------------------------------------------------------- # External Tax Computation # ------------------------------------------------------------------------- def _compute_external_taxes(self): """Call the external tax provider and update invoice tax lines. For each invoice in the recordset: 1. Identifies the active external provider. 2. Sends the product lines to the provider's ``calculate_tax`` method. 3. Updates or creates tax lines on the invoice to reflect the externally computed amounts. 4. Stores the external document code for later void/adjustment. """ provider_model = self.env['fusion.external.tax.provider'] for move in self: if move.move_type not in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'): continue provider = move.fusion_tax_provider_id or provider_model.get_provider( company=move.company_id, ) if not provider: _logger.info( "No active external tax provider for company %s, skipping move %s.", move.company_id.name, move.name, ) continue product_lines = move.invoice_line_ids.filtered( lambda l: l.display_type == 'product' ) if not product_lines: continue _logger.info( "Computing external taxes for move %s via provider '%s'.", move.name or 'Draft', provider.name, ) try: tax_result = provider.calculate_tax(product_lines) except UserError: raise except Exception as exc: raise UserError(_( "External tax computation failed for invoice %(ref)s:\n%(error)s", ref=move.name or 'Draft', error=str(exc), )) # Apply results move._apply_external_tax_result(tax_result, provider) def _apply_external_tax_result(self, tax_result, provider): """Write the external tax computation result onto the invoice. Creates or updates a dedicated tax line for the externally computed tax amount and records metadata about the computation. :param tax_result: ``dict`` returned by ``provider.calculate_tax()``. :param provider: ``fusion.external.tax.provider`` record. """ self.ensure_one() doc_code = tax_result.get('doc_code', '') total_tax = tax_result.get('total_tax', 0.0) # Find or create a dedicated "External Tax" account.tax record external_tax = self._get_or_create_external_tax_record(provider) # Update per-line tax amounts from provider response line_results = {lr['line_id']: lr for lr in tax_result.get('lines', []) if lr.get('line_id')} for line in self.invoice_line_ids.filtered(lambda l: l.display_type == 'product'): lr = line_results.get(line.id) if lr and external_tax: # Ensure the external tax is applied to the line if external_tax not in line.tax_ids: line.tax_ids = [Command.link(external_tax.id)] # Store external tax metadata self.write({ 'fusion_is_tax_computed_externally': True, 'fusion_tax_provider_id': provider.id, 'fusion_external_doc_code': doc_code, 'fusion_external_tax_amount': total_tax, 'fusion_external_tax_date': fields.Datetime.now(), }) _logger.info( "External tax applied: move=%s doc_code=%s total_tax=%s", self.name, doc_code, total_tax, ) def _get_or_create_external_tax_record(self, provider): """Find or create a placeholder ``account.tax`` for external tax lines. The placeholder tax record allows the externally computed amount to be recorded in the standard tax line infrastructure without conflicting with manually configured taxes. :param provider: Active ``fusion.external.tax.provider`` record. :returns: ``account.tax`` record or ``False``. """ company = self.company_id tax_xmlid = f"fusion_accounting.external_tax_{provider.code}_{company.id}" existing = self.env.ref(tax_xmlid, raise_if_not_found=False) if existing: return existing # Find a suitable tax account (default tax payable) tax_account = ( company.account_sale_tax_id.invoice_repartition_line_ids .filtered(lambda rl: rl.repartition_type == 'tax')[:1] .account_id ) if not tax_account: # Fall back to searching for a tax payable account tax_account = self.env['account.account'].search([ ('company_ids', 'in', company.id), ('account_type', '=', 'liability_current'), ], limit=1) if not tax_account: _logger.warning( "No tax account found for external tax placeholder (company=%s). " "External tax lines may not be properly recorded.", company.name, ) return False # Create the placeholder tax tax_vals = { 'name': f"External Tax ({provider.name})", 'type_tax_use': 'sale', 'amount_type': 'fixed', 'amount': 0.0, 'company_id': company.id, 'active': True, 'invoice_repartition_line_ids': [ Command.create({'repartition_type': 'base', 'factor_percent': 100.0}), Command.create({ 'repartition_type': 'tax', 'factor_percent': 100.0, 'account_id': tax_account.id, }), ], 'refund_repartition_line_ids': [ Command.create({'repartition_type': 'base', 'factor_percent': 100.0}), Command.create({ 'repartition_type': 'tax', 'factor_percent': 100.0, 'account_id': tax_account.id, }), ], } new_tax = self.env['account.tax'].create(tax_vals) # Register under an XML ID for future lookups self.env['ir.model.data'].create({ 'name': f"external_tax_{provider.code}_{company.id}", 'module': 'fusion_accounting', 'model': 'account.tax', 'res_id': new_tax.id, 'noupdate': True, }) return new_tax # ------------------------------------------------------------------------- # Void External Taxes # ------------------------------------------------------------------------- def _void_external_taxes(self): """Void previously committed external tax transactions. Called when an invoice is reset to draft, ensuring that the tax provider marks the corresponding transaction as voided. """ for move in self: if not move.fusion_is_tax_computed_externally or not move.fusion_external_doc_code: continue provider = move.fusion_tax_provider_id if not provider: _logger.warning( "Cannot void external taxes for move %s: no provider linked.", move.name, ) continue doc_type_map = { 'out_invoice': 'SalesInvoice', 'out_refund': 'ReturnInvoice', 'in_invoice': 'PurchaseInvoice', 'in_refund': 'ReturnInvoice', } doc_type = doc_type_map.get(move.move_type, 'SalesInvoice') try: provider.void_transaction(move.fusion_external_doc_code, doc_type=doc_type) _logger.info( "Voided external tax transaction: move=%s doc_code=%s", move.name, move.fusion_external_doc_code, ) except UserError as exc: _logger.warning( "Failed to void external tax for move %s: %s", move.name, exc, ) move.write({ 'fusion_is_tax_computed_externally': False, 'fusion_external_doc_code': False, 'fusion_external_tax_amount': 0.0, 'fusion_external_tax_date': False, }) # ------------------------------------------------------------------------- # Post Override # ------------------------------------------------------------------------- def _post(self, soft=True): """Compute external taxes before the standard posting workflow. Invoices that have an active external tax provider (and have not already been computed) will have their taxes calculated via the external service prior to validation and posting. """ # Compute external taxes for eligible invoices before posting for move in self: if ( move.fusion_use_external_tax and not move.fusion_is_tax_computed_externally and move.move_type in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund') ): move._compute_external_taxes() return super()._post(soft=soft) def button_draft(self): """Void external tax transactions when resetting to draft.""" # Void external taxes before resetting moves_with_external = self.filtered('fusion_is_tax_computed_externally') if moves_with_external: moves_with_external._void_external_taxes() return super().button_draft() # ------------------------------------------------------------------------- # Actions # ------------------------------------------------------------------------- def action_compute_external_taxes(self): """Manual button action to (re-)compute external taxes on the invoice.""" for move in self: if move.state == 'posted': raise UserError(_( "Cannot recompute taxes on posted invoice %(ref)s. " "Reset to draft first.", ref=move.name, )) self._compute_external_taxes() return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _("External Tax Computation"), 'message': _("Taxes have been computed successfully."), 'type': 'success', 'sticky': False, }, } def action_void_external_taxes(self): """Manual button action to void external taxes on the invoice.""" self._void_external_taxes() return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _("External Tax Void"), 'message': _("External tax transactions have been voided."), 'type': 'info', 'sticky': False, }, }