Initial commit
This commit is contained in:
625
Fusion Accounting/models/avatax_provider.py
Normal file
625
Fusion Accounting/models/avatax_provider.py
Normal 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']
|
||||
)
|
||||
Reference in New Issue
Block a user