diff --git a/account_us_wa_salestax/__init__.py b/account_us_wa_salestax/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/account_us_wa_salestax/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_us_wa_salestax/__manifest__.py b/account_us_wa_salestax/__manifest__.py new file mode 100644 index 00000000..3bf44876 --- /dev/null +++ b/account_us_wa_salestax/__manifest__.py @@ -0,0 +1,13 @@ +{'name': 'US WA State SalesTax API', + 'version': '10.0.1.0.0', + 'category': 'Tools', + 'depends': ['account', + ], + 'author': 'Hibou Corp.', + 'license': 'AGPL-3', + 'website': 'https://hibou.io/', + 'data': ['views/account_fiscal_position_view.xml', + ], + 'installable': True, + 'application': False, + } diff --git a/account_us_wa_salestax/models/__init__.py b/account_us_wa_salestax/models/__init__.py new file mode 100644 index 00000000..d0e78197 --- /dev/null +++ b/account_us_wa_salestax/models/__init__.py @@ -0,0 +1,2 @@ +from . import account_fiscal_position +from . import wa_tax_request diff --git a/account_us_wa_salestax/models/account_fiscal_position.py b/account_us_wa_salestax/models/account_fiscal_position.py new file mode 100644 index 00000000..cdaa0738 --- /dev/null +++ b/account_us_wa_salestax/models/account_fiscal_position.py @@ -0,0 +1,78 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + +from .wa_tax_request import WATaxRequest + + +class AccountFiscalPosition(models.Model): + _inherit = 'account.fiscal.position' + + is_us_wa = fields.Boolean(string='Use WA State API') + wa_base_tax_id = fields.Many2one('account.tax', string='WA Base/Error Tax') + + @api.multi + def map_tax(self, taxes, product=None, partner=None): + + if not taxes or not self.is_us_wa or partner is None: + return super(AccountFiscalPosition, self).map_tax(taxes) + + AccountTax = self.env['account.tax'].sudo() + result = AccountTax.browse() + + for tax in taxes: + # step 1: If we were to save the location code on the partner we might not have to do this + request = WATaxRequest() + res = request.get_rate(partner) + wa_tax = None + if not request.is_success(res): + # Cache. + wa_tax = AccountTax.search([ + ('wa_location_zips', 'like', '%' + partner.zip + '%'), + ('amount_type', '=', 'percent'), + ('type_tax_use', '=', 'sale')], limit=1) + if not wa_tax: + result |= self.wa_base_tax_id + continue + + # step 2: Find or create tax + if not wa_tax: + wa_tax = AccountTax.search([ + ('wa_location_code', '=', res['location_code']), + ('amount', '=', res['rate']), + ('amount_type', '=', 'percent'), + ('type_tax_use', '=', 'sale')], limit=1) + if not wa_tax: + wa_tax = AccountTax.create({ + 'name': '%s - WA Tax %s %%' % (res['location_code'], res['rate']), + 'wa_location_code': res['location_code'], + 'amount': res['rate'], + 'amount_type': 'percent', + 'type_tax_use': 'sale', + 'account_id': self.wa_base_tax_id.account_id.id, + 'refund_account_id': self.wa_base_tax_id.refund_account_id.id + }) + + if not wa_tax.wa_location_zips: + wa_tax.wa_location_zips = partner.zip + elif not wa_tax.wa_location_zips.find(partner.zip) >= 0: + zips = wa_tax.wa_location_zips.split(',') + zips.append(partner.zip) + wa_tax.wa_location_zips = zips.append(',') + + # step 3: Find or create mapping + tax_line = self.tax_ids.filtered(lambda x: x.tax_src_id.id == tax.id and x.tax_dest_id.id == wa_tax.id) + if not tax_line: + tax_line = self.env['account.fiscal.position.tax'].sudo().create({ + 'position_id': self.id, + 'tax_src_id': tax.id, + 'tax_dest_id': wa_tax.id, + }) + + result |= tax_line.tax_dest_id + return result + +class AccountTax(models.Model): + _inherit = 'account.tax' + + wa_location_code = fields.Integer('WA Location Code') + wa_location_zips = fields.Char('WA Location ZIPs', default='') diff --git a/account_us_wa_salestax/models/wa_tax_request.py b/account_us_wa_salestax/models/wa_tax_request.py new file mode 100644 index 00000000..e9f0242f --- /dev/null +++ b/account_us_wa_salestax/models/wa_tax_request.py @@ -0,0 +1,74 @@ +from urllib.request import urlopen, quote +from urllib.error import HTTPError +from ssl import _create_unverified_context +from logging import getLogger + +from odoo.exceptions import ValidationError + +_logger = getLogger(__name__) + + +class WATaxRequest(object): + def __init__(self): + pass + + def get_rate(self, partner): + # https://webgis.dor.wa.gov/webapi/addressrates.aspx/?output=text\&addr=test\&city=Marysville\&zip=98270 + if not all((partner.street, partner.city, partner.zip)): + raise ValidationError('WATaxRequest impossible without Street, City and ZIP.') + url = 'https://webgis.dor.wa.gov/webapi/addressrates.aspx?output=text&addr=' + quote(partner.street) + \ + '&city=' + quote(partner.city) + '&zip=' + quote(partner.zip) + _logger.info(url) + try: + response = urlopen(url, context=_create_unverified_context()) + response_body = response.read() + _logger.info(response_body) + except HTTPError as e: + _logger.warn('Error on request: ' + str(e)) + response_body = '' + + return self._parse_rate(response_body) + + def is_success(self, result): + ''' + ADDRESS = 0, + LATLON = 0, + PLUS4 = 1, + ADDRESS_STANARDIZED = 2, + PLUS4_STANARDIZED = 3, + ADDRESS_CHANGED = 4, + ZIPCODE = 5, + ADDRESS_NOT_FOUND = 6, + LATLON_NOT_FOUND = 7, + POI = 8, + ERROR = 9 + internal parse_error = 100 + ''' + if 'result_code' not in result or result['result_code'] >= 9 or result['result_code'] < 0: + return False + return True + + def _parse_rate(self, response_body): + # 'LocationCode=1704 Rate=0.100 ResultCode=0' + # { + # 'result_code': 0, + # 'location_code': '1704', + # 'rate': '10.00', + # } + + res = {'result_code': 100} + if len(response_body) > 200: + # this likely means that they returned an HTML page + return res + + body_parts = response_body.decode().split(' ') + for part in body_parts: + if part.find('ResultCode=') >= 0: + res['result_code'] = int(part[len('ResultCode='):]) + elif part.find('Rate=') >= 0: + res['rate'] = '%.2f' % (float(part[len('Rate='):]) * 100.0) + elif part.find('LocationCode=') >= 0: + res['location_code'] = part[len('LocationCode='):] + elif part.find('debughint=') >= 0: + res['debug_hint'] = part[len('debughint='):] + return res diff --git a/account_us_wa_salestax/views/account_fiscal_position_view.xml b/account_us_wa_salestax/views/account_fiscal_position_view.xml new file mode 100644 index 00000000..59ae7989 --- /dev/null +++ b/account_us_wa_salestax/views/account_fiscal_position_view.xml @@ -0,0 +1,28 @@ + + + + + account.fiscal.position.form.inherit + account.fiscal.position + + + + + + + + + + + account.tax.form.inherit + account.tax + + + + + + + + + +