""" 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 ``. :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'] )