286 lines
11 KiB
Python
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)
|