Initial commit
This commit is contained in:
375
Fusion Accounting/models/account_move_external_tax.py
Normal file
375
Fusion Accounting/models/account_move_external_tax.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""
|
||||
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,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user