""" 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)