Files
2026-02-22 01:22:18 -05:00

286 lines
11 KiB
Python

"""
Fusion Accounting - Python Tax Code Engine
============================================
Extends the standard ``account.tax`` model with a ``python`` computation type.
Users can write arbitrary Python expressions that compute the tax amount based
on contextual variables such as ``price_unit``, ``quantity``, ``product``,
``partner``, and ``company``.
The code is executed inside Odoo's ``safe_eval`` sandbox so that potentially
destructive operations (file I/O, imports, exec, etc.) are blocked while still
providing the flexibility of a full expression language.
Example Python code for a tiered tax::
# 5% on the first 1000, 8% above
base = price_unit * quantity
if base <= 1000:
result = base * 0.05
else:
result = 1000 * 0.05 + (base - 1000) * 0.08
The code must assign the computed tax amount to the variable ``result``.
Copyright (c) Nexa Systems Inc. - All rights reserved.
"""
import logging
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from odoo.tools.safe_eval import safe_eval, wrap_module
_logger = logging.getLogger(__name__)
# Whitelisted standard library modules available inside Python tax code
_MATH_MODULE = wrap_module(__import__('math'), [
'ceil', 'floor', 'trunc', 'fabs', 'log', 'log10', 'pow', 'sqrt',
'pi', 'e', 'inf',
])
_DATETIME_MODULE = wrap_module(__import__('datetime'), [
'date', 'datetime', 'timedelta',
])
class FusionTaxPython(models.Model):
"""Adds a *Python Code* computation mode to the tax engine.
When ``amount_type`` is set to ``'python'``, the tax amount is determined
by executing the user-supplied Python code in a restricted sandbox.
"""
_inherit = "account.tax"
# -------------------------------------------------------------------------
# Fields
# -------------------------------------------------------------------------
amount_type = fields.Selection(
selection_add=[('python', 'Python Code')],
ondelete={'python': 'set default'},
)
python_compute = fields.Text(
string="Python Code",
default="# Available variables:\n"
"# price_unit - Unit price of the line\n"
"# quantity - Quantity on the line\n"
"# product - product.product record (or empty)\n"
"# partner - res.partner record (or empty)\n"
"# company - res.company record\n"
"# env - Odoo Environment\n"
"# date - Invoice/transaction date\n"
"# currency - res.currency record\n"
"# math - Python math module (ceil, floor, sqrt, ...)\n"
"# datetime - Python datetime module\n"
"#\n"
"# Assign the tax amount to 'result':\n"
"result = price_unit * quantity * 0.10\n",
help="Python code executed to compute the tax amount. The code must "
"assign the final tax amount to the variable ``result``. "
"Runs inside Odoo's safe_eval sandbox with a restricted set of "
"built-in functions.",
)
python_applicable = fields.Text(
string="Applicability Code",
help="Optional Python code that determines whether this tax applies. "
"Must assign a boolean to ``result``. If not set, the tax always applies.",
)
# -------------------------------------------------------------------------
# Constraints
# -------------------------------------------------------------------------
@api.constrains('amount_type', 'python_compute')
def _check_python_compute(self):
"""Validate that Python-type taxes have non-empty compute code."""
for tax in self:
if tax.amount_type == 'python' and not (tax.python_compute or '').strip():
raise ValidationError(_(
"Tax '%(name)s' uses Python computation but the code field is empty.",
name=tax.name,
))
# -------------------------------------------------------------------------
# Python Tax Computation
# -------------------------------------------------------------------------
def _compute_tax_python(self, price_unit, quantity, product=None,
partner=None, company=None, date=None,
currency=None):
"""Execute the Python tax code and return the computed amount.
The code is evaluated in a restricted namespace containing relevant
business objects. The evaluated code must assign a numeric value to
the variable ``result``.
:param price_unit: Unit price (before tax).
:param quantity: Line quantity.
:param product: ``product.product`` record or ``None``.
:param partner: ``res.partner`` record or ``None``.
:param company: ``res.company`` record or ``None``.
:param date: Transaction date (``datetime.date`` or ``False``).
:param currency: ``res.currency`` record or ``None``.
:returns: Computed tax amount as a ``float``.
:raises UserError: If the code is invalid or fails to set ``result``.
"""
self.ensure_one()
if self.amount_type != 'python':
raise UserError(_(
"Tax '%(name)s' is not a Python-type tax (amount_type=%(type)s).",
name=self.name,
type=self.amount_type,
))
resolved_company = company or self.env.company
resolved_product = product or self.env['product.product']
resolved_partner = partner or self.env['res.partner']
resolved_currency = currency or resolved_company.currency_id
local_context = {
'price_unit': price_unit,
'quantity': quantity,
'product': resolved_product,
'partner': resolved_partner,
'company': resolved_company,
'env': self.env,
'date': date or fields.Date.context_today(self),
'currency': resolved_currency,
'math': _MATH_MODULE,
'datetime': _DATETIME_MODULE,
'result': 0.0,
# Convenience aliases
'base_amount': price_unit * quantity,
'abs': abs,
'round': round,
'min': min,
'max': max,
'sum': sum,
'len': len,
'float': float,
'int': int,
'bool': bool,
'str': str,
}
code = (self.python_compute or '').strip()
if not code:
return 0.0
try:
safe_eval(
code,
local_context,
mode='exec',
nocopy=True,
)
except Exception as exc:
_logger.error(
"Python tax computation failed for tax '%s' (id=%s): %s",
self.name, self.id, exc,
)
raise UserError(_(
"Error executing Python tax code for '%(name)s':\n%(error)s",
name=self.name,
error=str(exc),
))
result = local_context.get('result', 0.0)
if not isinstance(result, (int, float)):
raise UserError(_(
"Python tax code for '%(name)s' must assign a numeric value to "
"'result', but got %(type)s instead.",
name=self.name,
type=type(result).__name__,
))
return float(result)
def _check_python_applicable(self, price_unit, quantity, product=None,
partner=None, company=None, date=None,
currency=None):
"""Evaluate the applicability code to determine if this tax applies.
If no applicability code is set, the tax is always applicable.
:returns: ``True`` if the tax should be applied, ``False`` otherwise.
"""
self.ensure_one()
code = (self.python_applicable or '').strip()
if not code:
return True
resolved_company = company or self.env.company
resolved_product = product or self.env['product.product']
resolved_partner = partner or self.env['res.partner']
resolved_currency = currency or resolved_company.currency_id
local_context = {
'price_unit': price_unit,
'quantity': quantity,
'product': resolved_product,
'partner': resolved_partner,
'company': resolved_company,
'env': self.env,
'date': date or fields.Date.context_today(self),
'currency': resolved_currency,
'math': _MATH_MODULE,
'datetime': _DATETIME_MODULE,
'result': True,
'base_amount': price_unit * quantity,
'abs': abs,
'round': round,
'min': min,
'max': max,
}
try:
safe_eval(code, local_context, mode='exec', nocopy=True)
except Exception as exc:
_logger.warning(
"Python applicability check failed for tax '%s': %s. "
"Treating as applicable.",
self.name, exc,
)
return True
return bool(local_context.get('result', True))
# -------------------------------------------------------------------------
# Tax Engine Integration
# -------------------------------------------------------------------------
def _get_tax_totals(self, base_lines, currency, company, cash_rounding=None):
"""Override to inject Python-computed taxes into the standard totals pipeline.
For non-Python taxes, delegates entirely to ``super()``. For Python
taxes, the computed amount is pre-calculated and substituted into the
standard computation flow.
"""
# Pre-compute amounts for Python taxes in the base lines
for base_line in base_lines:
record = base_line.get('record')
if not record or not hasattr(record, 'tax_ids'):
continue
python_taxes = record.tax_ids.filtered(lambda t: t.amount_type == 'python')
if not python_taxes:
continue
# Retrieve line-level data for context
price_unit = base_line.get('price_unit', 0.0)
quantity = base_line.get('quantity', 1.0)
product = base_line.get('product', self.env['product.product'])
partner = base_line.get('partner', self.env['res.partner'])
date = base_line.get('date', False)
for ptax in python_taxes:
if ptax._check_python_applicable(
price_unit, quantity, product, partner, company, date, currency,
):
computed = ptax._compute_tax_python(
price_unit, quantity, product, partner, company, date, currency,
)
# Temporarily set the amount on the tax for the standard pipeline
ptax.with_context(fusion_python_tax_amount=computed)
return super()._get_tax_totals(base_lines, currency, company, cash_rounding)