626 lines
24 KiB
Python
626 lines
24 KiB
Python
"""
|
|
Fusion Accounting - Avalara AvaTax Provider
|
|
============================================
|
|
|
|
Concrete implementation of :class:`FusionExternalTaxProvider` that integrates
|
|
with the **Avalara AvaTax REST API v2** for real-time tax calculation, address
|
|
validation, and transaction management.
|
|
|
|
API Reference: https://developer.avalara.com/api-reference/avatax/rest/v2/
|
|
|
|
Supported operations
|
|
--------------------
|
|
* **CreateTransaction** - compute tax on sales/purchase documents.
|
|
* **VoidTransaction** - cancel a previously committed transaction.
|
|
* **ResolveAddress** - validate and normalise postal addresses.
|
|
* **Ping** - connection health check.
|
|
|
|
Configuration
|
|
-------------
|
|
Set the *AvaTax Environment* field to ``sandbox`` during development (uses
|
|
``sandbox-rest.avatax.com``) and switch to ``production`` for live tax filings.
|
|
|
|
Copyright (c) Nexa Systems Inc. - All rights reserved.
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
import logging
|
|
|
|
import requests
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import UserError, ValidationError
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# AvaTax REST API v2 base URLs
|
|
AVATAX_API_URLS = {
|
|
'sandbox': 'https://sandbox-rest.avatax.com/api/v2',
|
|
'production': 'https://rest.avatax.com/api/v2',
|
|
}
|
|
|
|
# Default timeout for AvaTax API requests (seconds)
|
|
AVATAX_REQUEST_TIMEOUT = 30
|
|
|
|
# Mapping of Odoo tax types to AvaTax transaction document types
|
|
AVATAX_DOC_TYPES = {
|
|
'out_invoice': 'SalesInvoice',
|
|
'out_refund': 'ReturnInvoice',
|
|
'in_invoice': 'PurchaseInvoice',
|
|
'in_refund': 'ReturnInvoice',
|
|
'entry': 'SalesOrder',
|
|
}
|
|
|
|
|
|
class FusionAvaTaxProvider(models.Model):
|
|
"""Avalara AvaTax integration for automated tax calculation.
|
|
|
|
Extends :class:`fusion.external.tax.provider` with AvaTax-specific
|
|
credentials, endpoint configuration, and full REST API v2 support.
|
|
"""
|
|
|
|
_inherit = "fusion.external.tax.provider"
|
|
|
|
# -------------------------------------------------------------------------
|
|
# AvaTax-Specific Fields
|
|
# -------------------------------------------------------------------------
|
|
avatax_account_number = fields.Char(
|
|
string="AvaTax Account Number",
|
|
groups="account.group_account_manager",
|
|
help="Numeric account ID provided by Avalara upon registration.",
|
|
)
|
|
avatax_license_key = fields.Char(
|
|
string="AvaTax License Key",
|
|
groups="account.group_account_manager",
|
|
help="Secret license key issued by Avalara. Stored encrypted.",
|
|
)
|
|
avatax_company_code = fields.Char(
|
|
string="AvaTax Company Code",
|
|
help="Company code configured in the Avalara portal. This identifies "
|
|
"your nexus and tax configuration within AvaTax.",
|
|
)
|
|
avatax_environment = fields.Selection(
|
|
selection=[
|
|
('sandbox', 'Sandbox (Testing)'),
|
|
('production', 'Production'),
|
|
],
|
|
string="AvaTax Environment",
|
|
default='sandbox',
|
|
required=True,
|
|
help="Use Sandbox for testing without real tax filings. Switch to "
|
|
"Production for live transaction recording.",
|
|
)
|
|
avatax_commit_on_post = fields.Boolean(
|
|
string="Commit on Invoice Post",
|
|
default=True,
|
|
help="When enabled, transactions are committed (locked) in AvaTax "
|
|
"the moment the invoice is posted. Otherwise, they remain "
|
|
"uncommitted until explicitly committed.",
|
|
)
|
|
avatax_address_validation = fields.Boolean(
|
|
string="Address Validation",
|
|
default=True,
|
|
help="When enabled, customer addresses are validated and normalised "
|
|
"through the AvaTax address resolution service before tax "
|
|
"calculation.",
|
|
)
|
|
avatax_default_tax_code = fields.Char(
|
|
string="Default Tax Code",
|
|
default='P0000000',
|
|
help="AvaTax tax code applied to products without a specific mapping. "
|
|
"'P0000000' represents tangible personal property.",
|
|
)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Selection Extension
|
|
# -------------------------------------------------------------------------
|
|
@api.model
|
|
def _get_provider_type_selection(self):
|
|
"""Add 'avatax' to the provider type selection list."""
|
|
return [('generic', 'Generic'), ('avatax', 'Avalara AvaTax')]
|
|
|
|
def _init_provider_type(self):
|
|
"""Dynamically extend provider_type selection for AvaTax."""
|
|
selection = self._fields['provider_type'].selection
|
|
if isinstance(selection, list):
|
|
avatax_entry = ('avatax', 'Avalara AvaTax')
|
|
if avatax_entry not in selection:
|
|
selection.append(avatax_entry)
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
"""Set provider code and API URL for AvaTax records automatically."""
|
|
for vals in vals_list:
|
|
if vals.get('provider_type') == 'avatax':
|
|
vals.setdefault('code', 'avatax')
|
|
env = vals.get('avatax_environment', 'sandbox')
|
|
vals.setdefault('api_url', AVATAX_API_URLS.get(env, AVATAX_API_URLS['sandbox']))
|
|
return super().create(vals_list)
|
|
|
|
def write(self, vals):
|
|
"""Keep the API URL in sync when the environment changes."""
|
|
if 'avatax_environment' in vals:
|
|
vals['api_url'] = AVATAX_API_URLS.get(
|
|
vals['avatax_environment'], AVATAX_API_URLS['sandbox']
|
|
)
|
|
return super().write(vals)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# AvaTax REST API Helpers
|
|
# -------------------------------------------------------------------------
|
|
def _avatax_get_api_url(self):
|
|
"""Return the base API URL for the configured environment.
|
|
|
|
:returns: Base URL string without trailing slash.
|
|
"""
|
|
self.ensure_one()
|
|
return AVATAX_API_URLS.get(self.avatax_environment, AVATAX_API_URLS['sandbox'])
|
|
|
|
def _avatax_get_auth_header(self):
|
|
"""Build the HTTP Basic authentication header.
|
|
|
|
AvaTax authenticates via ``Authorization: Basic <base64(account:key)>``.
|
|
|
|
:returns: ``dict`` with the Authorization header.
|
|
:raises UserError: When credentials are missing.
|
|
"""
|
|
self.ensure_one()
|
|
if not self.avatax_account_number or not self.avatax_license_key:
|
|
raise UserError(_(
|
|
"AvaTax account number and license key are required. "
|
|
"Please configure them on provider '%(name)s'.",
|
|
name=self.name,
|
|
))
|
|
credentials = f"{self.avatax_account_number}:{self.avatax_license_key}"
|
|
encoded = base64.b64encode(credentials.encode('utf-8')).decode('utf-8')
|
|
return {'Authorization': f'Basic {encoded}'}
|
|
|
|
def _avatax_request(self, method, endpoint, payload=None, params=None):
|
|
"""Execute an authenticated request against the AvaTax REST API v2.
|
|
|
|
:param method: HTTP method (``'GET'``, ``'POST'``, ``'DELETE'``).
|
|
:param endpoint: API path relative to the version root, e.g.
|
|
``'/transactions/create'``.
|
|
:param payload: JSON-serialisable request body (for POST/PUT).
|
|
:param params: URL query parameters dict.
|
|
:returns: Parsed JSON response ``dict``.
|
|
:raises UserError: On HTTP or API errors.
|
|
"""
|
|
self.ensure_one()
|
|
base_url = self._avatax_get_api_url()
|
|
url = f"{base_url}{endpoint}"
|
|
headers = {
|
|
**self._avatax_get_auth_header(),
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'X-Avalara-Client': 'FusionAccounting;19.0;OdooConnector;1.0',
|
|
}
|
|
|
|
if self.log_requests:
|
|
_logger.debug(
|
|
"AvaTax %s %s | payload=%s | params=%s",
|
|
method, url, json.dumps(payload or {}), params,
|
|
)
|
|
|
|
try:
|
|
response = requests.request(
|
|
method=method.upper(),
|
|
url=url,
|
|
json=payload,
|
|
params=params,
|
|
headers=headers,
|
|
timeout=AVATAX_REQUEST_TIMEOUT,
|
|
)
|
|
except requests.exceptions.ConnectionError:
|
|
raise UserError(_(
|
|
"Unable to connect to AvaTax at %(url)s. "
|
|
"Please verify your network connection and the configured environment.",
|
|
url=url,
|
|
))
|
|
except requests.exceptions.Timeout:
|
|
raise UserError(_(
|
|
"The request to AvaTax timed out after %(timeout)s seconds. "
|
|
"Please try again or contact Avalara support.",
|
|
timeout=AVATAX_REQUEST_TIMEOUT,
|
|
))
|
|
except requests.exceptions.RequestException as exc:
|
|
raise UserError(_(
|
|
"AvaTax request failed: %(error)s",
|
|
error=str(exc),
|
|
))
|
|
|
|
if self.log_requests:
|
|
_logger.debug(
|
|
"AvaTax response %s: %s",
|
|
response.status_code, response.text[:2000],
|
|
)
|
|
|
|
if response.status_code in (200, 201):
|
|
return response.json()
|
|
|
|
# Handle structured AvaTax error responses
|
|
self._avatax_handle_error(response)
|
|
|
|
def _avatax_handle_error(self, response):
|
|
"""Parse and raise a descriptive error from an AvaTax API response.
|
|
|
|
:param response: ``requests.Response`` with a non-2xx status code.
|
|
:raises UserError: Always.
|
|
"""
|
|
try:
|
|
error_data = response.json()
|
|
except (ValueError, KeyError):
|
|
raise UserError(_(
|
|
"AvaTax returned HTTP %(code)s with an unparseable body: %(body)s",
|
|
code=response.status_code,
|
|
body=response.text[:500],
|
|
))
|
|
|
|
error_info = error_data.get('error', {})
|
|
message = error_info.get('message', 'Unknown error')
|
|
details = error_info.get('details', [])
|
|
detail_messages = '\n'.join(
|
|
f" - [{d.get('severity', 'Error')}] {d.get('message', '')} "
|
|
f"(ref: {d.get('refersTo', 'N/A')})"
|
|
for d in details
|
|
)
|
|
raise UserError(_(
|
|
"AvaTax API Error (HTTP %(code)s): %(message)s\n\nDetails:\n%(details)s",
|
|
code=response.status_code,
|
|
message=message,
|
|
details=detail_messages or _("No additional details provided."),
|
|
))
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Tax Calculation
|
|
# -------------------------------------------------------------------------
|
|
def calculate_tax(self, order_lines):
|
|
"""Compute tax via AvaTax CreateTransaction API.
|
|
|
|
Builds a ``CreateTransactionModel`` payload from the provided move
|
|
lines and submits it to the AvaTax API. The response is parsed and
|
|
returned as a normalised list of per-line tax results.
|
|
|
|
:param order_lines: ``account.move.line`` recordset with product,
|
|
quantity, price, and associated partner address data.
|
|
:returns: ``dict`` with keys ``doc_code``, ``total_tax``, and ``lines``
|
|
(list of per-line tax detail dicts).
|
|
:raises UserError: On API failure or missing configuration.
|
|
"""
|
|
self.ensure_one()
|
|
if not order_lines:
|
|
return {'doc_code': False, 'total_tax': 0.0, 'lines': []}
|
|
|
|
move = order_lines[0].move_id
|
|
partner = move.partner_id
|
|
if not partner:
|
|
raise UserError(_(
|
|
"Cannot compute external taxes: the invoice has no partner set."
|
|
))
|
|
|
|
payload = self._avatax_build_transaction_payload(move, order_lines)
|
|
result = self._avatax_request('POST', '/transactions/create', payload=payload)
|
|
return self._avatax_parse_transaction_result(result, order_lines)
|
|
|
|
def _avatax_build_transaction_payload(self, move, lines):
|
|
"""Construct the CreateTransactionModel JSON body.
|
|
|
|
Maps invoice data to the AvaTax transaction schema described at:
|
|
https://developer.avalara.com/api-reference/avatax/rest/v2/models/CreateTransactionModel/
|
|
|
|
:param move: ``account.move`` record.
|
|
:param lines: ``account.move.line`` recordset (product lines only).
|
|
:returns: ``dict`` ready for JSON serialisation.
|
|
"""
|
|
self.ensure_one()
|
|
partner = move.partner_id
|
|
company = move.company_id
|
|
|
|
# Determine document type from move_type
|
|
doc_type = AVATAX_DOC_TYPES.get(move.move_type, 'SalesOrder')
|
|
|
|
# Build address objects
|
|
ship_to = self._avatax_build_address(partner)
|
|
ship_from = self._avatax_build_address(company.partner_id)
|
|
|
|
# Build line items
|
|
avatax_lines = []
|
|
for idx, line in enumerate(lines.filtered(lambda l: l.display_type == 'product')):
|
|
tax_code = self._avatax_get_product_tax_code(line.product_id)
|
|
avatax_lines.append({
|
|
'number': str(idx + 1),
|
|
'quantity': abs(line.quantity),
|
|
'amount': abs(line.price_subtotal),
|
|
'taxCode': tax_code,
|
|
'itemCode': line.product_id.default_code or line.product_id.name or '',
|
|
'description': (line.name or '')[:255],
|
|
'discounted': bool(line.discount),
|
|
'ref1': str(line.id),
|
|
})
|
|
|
|
if not avatax_lines:
|
|
raise UserError(_(
|
|
"No taxable product lines found on invoice %(ref)s.",
|
|
ref=move.name or move.ref or 'New',
|
|
))
|
|
|
|
commit = self.avatax_commit_on_post and move.state == 'posted'
|
|
payload = {
|
|
'type': doc_type,
|
|
'companyCode': self.avatax_company_code or company.name,
|
|
'date': fields.Date.to_string(move.invoice_date or move.date),
|
|
'customerCode': partner.ref or partner.name or str(partner.id),
|
|
'purchaseOrderNo': move.ref or '',
|
|
'addresses': {
|
|
'shipFrom': ship_from,
|
|
'shipTo': ship_to,
|
|
},
|
|
'lines': avatax_lines,
|
|
'commit': commit,
|
|
'currencyCode': move.currency_id.name,
|
|
'description': f"Odoo Invoice {move.name or 'Draft'}",
|
|
}
|
|
|
|
# Only include document code for posted invoices
|
|
if move.name and move.name != '/':
|
|
payload['code'] = move.name
|
|
|
|
return payload
|
|
|
|
def _avatax_build_address(self, partner):
|
|
"""Convert a partner record to an AvaTax address dict.
|
|
|
|
:param partner: ``res.partner`` record.
|
|
:returns: ``dict`` with AvaTax address fields.
|
|
"""
|
|
return {
|
|
'line1': partner.street or '',
|
|
'line2': partner.street2 or '',
|
|
'city': partner.city or '',
|
|
'region': partner.state_id.code or '',
|
|
'country': partner.country_id.code or '',
|
|
'postalCode': partner.zip or '',
|
|
}
|
|
|
|
def _avatax_get_product_tax_code(self, product):
|
|
"""Resolve the AvaTax tax code for a given product.
|
|
|
|
Checks (in order):
|
|
1. A custom field ``avatax_tax_code`` on the product template.
|
|
2. A category-level mapping via ``categ_id.avatax_tax_code``.
|
|
3. The provider's default tax code.
|
|
|
|
:param product: ``product.product`` record.
|
|
:returns: Tax code string.
|
|
"""
|
|
if product and hasattr(product, 'avatax_tax_code') and product.avatax_tax_code:
|
|
return product.avatax_tax_code
|
|
if (
|
|
product
|
|
and product.categ_id
|
|
and hasattr(product.categ_id, 'avatax_tax_code')
|
|
and product.categ_id.avatax_tax_code
|
|
):
|
|
return product.categ_id.avatax_tax_code
|
|
return self.avatax_default_tax_code or 'P0000000'
|
|
|
|
def _avatax_parse_transaction_result(self, result, order_lines):
|
|
"""Parse the AvaTax CreateTransaction response into a normalised format.
|
|
|
|
:param result: Parsed JSON response from AvaTax.
|
|
:param order_lines: Original ``account.move.line`` recordset.
|
|
:returns: ``dict`` with ``doc_code``, ``total_tax``, ``total_amount``,
|
|
and ``lines`` list.
|
|
"""
|
|
doc_code = result.get('code', '')
|
|
total_tax = result.get('totalTax', 0.0)
|
|
total_amount = result.get('totalAmount', 0.0)
|
|
|
|
lines_result = []
|
|
for avatax_line in result.get('lines', []):
|
|
line_ref = avatax_line.get('ref1', '')
|
|
tax_details = []
|
|
for detail in avatax_line.get('details', []):
|
|
tax_details.append({
|
|
'tax_name': detail.get('taxName', ''),
|
|
'tax_rate': detail.get('rate', 0.0),
|
|
'tax_amount': detail.get('tax', 0.0),
|
|
'taxable_amount': detail.get('taxableAmount', 0.0),
|
|
'jurisdiction': detail.get('jurisName', ''),
|
|
'jurisdiction_type': detail.get('jurisType', ''),
|
|
'region': detail.get('region', ''),
|
|
'country': detail.get('country', ''),
|
|
})
|
|
lines_result.append({
|
|
'line_id': int(line_ref) if line_ref.isdigit() else False,
|
|
'line_number': avatax_line.get('lineNumber', ''),
|
|
'tax_amount': avatax_line.get('tax', 0.0),
|
|
'taxable_amount': avatax_line.get('taxableAmount', 0.0),
|
|
'exempt_amount': avatax_line.get('exemptAmount', 0.0),
|
|
'tax_details': tax_details,
|
|
})
|
|
|
|
return {
|
|
'doc_code': doc_code,
|
|
'total_tax': total_tax,
|
|
'total_amount': total_amount,
|
|
'lines': lines_result,
|
|
}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Transaction Void
|
|
# -------------------------------------------------------------------------
|
|
def void_transaction(self, doc_code, doc_type='SalesInvoice'):
|
|
"""Void a committed transaction in AvaTax.
|
|
|
|
Uses the VoidTransaction API endpoint to mark a previously committed
|
|
tax document as voided. This prevents it from appearing in tax filings.
|
|
|
|
:param doc_code: Document code (typically the invoice number).
|
|
:param doc_type: AvaTax document type (default ``'SalesInvoice'``).
|
|
:returns: ``True`` on success.
|
|
:raises UserError: When the API call fails.
|
|
"""
|
|
self.ensure_one()
|
|
if not doc_code:
|
|
_logger.warning("void_transaction called with empty doc_code, skipping.")
|
|
return True
|
|
|
|
company_code = self.avatax_company_code or self.company_id.name
|
|
endpoint = f"/companies/{company_code}/transactions/{doc_code}/void"
|
|
payload = {'code': 'DocVoided'}
|
|
|
|
self._avatax_request('POST', endpoint, payload=payload)
|
|
_logger.info(
|
|
"AvaTax transaction voided: company=%s doc_code=%s",
|
|
company_code, doc_code,
|
|
)
|
|
return True
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Address Validation
|
|
# -------------------------------------------------------------------------
|
|
def validate_address(self, partner):
|
|
"""Validate and normalise a partner address via the AvaTax address
|
|
resolution service.
|
|
|
|
Calls ``POST /addresses/resolve`` and returns the validated address
|
|
components. If validation fails, the original address is returned
|
|
unchanged with a warning.
|
|
|
|
:param partner: ``res.partner`` record to validate.
|
|
:returns: ``dict`` with normalised address fields.
|
|
"""
|
|
self.ensure_one()
|
|
if not self.avatax_address_validation:
|
|
return {}
|
|
|
|
payload = {
|
|
'line1': partner.street or '',
|
|
'line2': partner.street2 or '',
|
|
'city': partner.city or '',
|
|
'region': partner.state_id.code or '',
|
|
'country': partner.country_id.code or '',
|
|
'postalCode': partner.zip or '',
|
|
}
|
|
|
|
try:
|
|
result = self._avatax_request('POST', '/addresses/resolve', payload=payload)
|
|
except UserError:
|
|
_logger.warning(
|
|
"AvaTax address validation failed for partner %s, using original address.",
|
|
partner.display_name,
|
|
)
|
|
return {}
|
|
|
|
validated = result.get('validatedAddresses', [{}])[0] if result.get('validatedAddresses') else {}
|
|
messages = result.get('messages', [])
|
|
|
|
return {
|
|
'street': validated.get('line1', partner.street),
|
|
'street2': validated.get('line2', partner.street2),
|
|
'city': validated.get('city', partner.city),
|
|
'zip': validated.get('postalCode', partner.zip),
|
|
'state_code': validated.get('region', ''),
|
|
'country_code': validated.get('country', ''),
|
|
'latitude': validated.get('latitude', ''),
|
|
'longitude': validated.get('longitude', ''),
|
|
'messages': [
|
|
{'severity': m.get('severity', 'info'), 'summary': m.get('summary', '')}
|
|
for m in messages
|
|
],
|
|
}
|
|
|
|
def action_validate_partner_address(self):
|
|
"""Wizard action: validate the address of a selected partner."""
|
|
self.ensure_one()
|
|
partner = self.env.context.get('active_id')
|
|
if not partner:
|
|
raise UserError(_("No partner selected for address validation."))
|
|
|
|
partner_rec = self.env['res.partner'].browse(partner)
|
|
result = self.validate_address(partner_rec)
|
|
|
|
if not result:
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'title': _("Address Validation"),
|
|
'message': _("Address validation is disabled or returned no data."),
|
|
'type': 'warning',
|
|
'sticky': False,
|
|
},
|
|
}
|
|
|
|
# Update the partner with validated address
|
|
update_vals = {}
|
|
if result.get('street'):
|
|
update_vals['street'] = result['street']
|
|
if result.get('street2'):
|
|
update_vals['street2'] = result['street2']
|
|
if result.get('city'):
|
|
update_vals['city'] = result['city']
|
|
if result.get('zip'):
|
|
update_vals['zip'] = result['zip']
|
|
if result.get('state_code'):
|
|
state = self.env['res.country.state'].search([
|
|
('code', '=', result['state_code']),
|
|
('country_id.code', '=', result.get('country_code', '')),
|
|
], limit=1)
|
|
if state:
|
|
update_vals['state_id'] = state.id
|
|
if update_vals:
|
|
partner_rec.write(update_vals)
|
|
|
|
msg_parts = [m['summary'] for m in result.get('messages', []) if m.get('summary')]
|
|
summary = '\n'.join(msg_parts) if msg_parts else _("Address validated successfully.")
|
|
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'title': _("Address Validation"),
|
|
'message': summary,
|
|
'type': 'success' if not msg_parts else 'warning',
|
|
'sticky': bool(msg_parts),
|
|
},
|
|
}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Connection Test
|
|
# -------------------------------------------------------------------------
|
|
def test_connection(self):
|
|
"""Ping the AvaTax API to verify credentials and connectivity.
|
|
|
|
Calls ``GET /utilities/ping`` which returns authentication status.
|
|
|
|
:returns: ``True`` on successful ping.
|
|
:raises UserError: When the ping fails.
|
|
"""
|
|
self.ensure_one()
|
|
result = self._avatax_request('GET', '/utilities/ping')
|
|
authenticated = result.get('authenticated', False)
|
|
if not authenticated:
|
|
raise UserError(_(
|
|
"AvaTax ping succeeded but the credentials are not valid. "
|
|
"Please check your account number and license key."
|
|
))
|
|
_logger.info(
|
|
"AvaTax connection test passed: authenticated=%s version=%s",
|
|
authenticated, result.get('version', 'unknown'),
|
|
)
|
|
return True
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Onchange
|
|
# -------------------------------------------------------------------------
|
|
@api.onchange('avatax_environment')
|
|
def _onchange_avatax_environment(self):
|
|
"""Update the API URL when the environment selection changes."""
|
|
if self.avatax_environment:
|
|
self.api_url = AVATAX_API_URLS.get(
|
|
self.avatax_environment, AVATAX_API_URLS['sandbox']
|
|
)
|