Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View File

@@ -0,0 +1,625 @@
"""
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']
)