Webiant Logo Webiant Logo
  1. No results found.

    Try your search with a different keyword or use * as a wildcard.

AvalaraTaxManager.cs

using System.Globalization;
using System.Text;
using Avalara.AvaTax.RestClient;
using Newtonsoft.Json;
using Nop.Core;
using Nop.Core.Caching;
using Nop.Core.Domain.Common;
using Nop.Core.Domain.Customers;
using Nop.Core.Domain.Orders;
using Nop.Core.Domain.Shipping;
using Nop.Core.Domain.Tax;
using Nop.Core.Infrastructure;
using Nop.Data;
using Nop.Plugin.Tax.Avalara.Domain;
using Nop.Plugin.Tax.Avalara.ItemClassificationAPI;
using Nop.Services.Attributes;
using Nop.Services.Catalog;
using Nop.Services.Common;
using Nop.Services.Customers;
using Nop.Services.Directory;
using Nop.Services.Logging;
using Nop.Services.Orders;
using Nop.Services.Payments;
using Nop.Services.Tax;

namespace Nop.Plugin.Tax.Avalara.Services;

/// <summary>
/// Represents the manager that operates with requests to the Avalara services
/// </summary>
public class AvalaraTaxManager : IDisposable
{
    #region Fields

    protected readonly AvalaraTaxSettings _avalaraTaxSettings;
    protected readonly IAddressService _addressService;
    protected readonly IAttributeParser<CheckoutAttribute, CheckoutAttributeValue> _checkoutAttributeParser;
    protected readonly ICategoryService _categoryService;
    protected readonly ICountryService _countryService;
    protected readonly ICustomerService _customerService;
    protected readonly IGenericAttributeService _genericAttributeService;
    protected readonly IGeoLookupService _geoLookupService;
    protected readonly ILogger _logger;
    protected readonly INopFileProvider _fileProvider;
    protected readonly IOrderService _orderService;
    protected readonly IOrderTotalCalculationService _orderTotalCalculationService;
    protected readonly IPaymentService _paymentService;
    protected readonly IProductAttributeService _productAttributeService;
    protected readonly IProductService _productService;
    protected readonly IRepository<GenericAttribute> _genericAttributeRepository;
    protected readonly IRepository<TaxCategory> _taxCategoryRepository;
    protected readonly IShoppingCartService _shoppingCartService;
    protected readonly IStateProvinceService _stateProvinceService;
    protected readonly IStaticCacheManager _staticCacheManager;
    protected readonly ITaxCategoryService _taxCategoryService;
    protected readonly ItemClassificationHttpClient _itemClassificationHttpClient;
    protected readonly ItemClassificationService _itemClassificationService;
    protected readonly IWorkContext _workContext;
    protected readonly ShippingSettings _shippingSettings;
    protected readonly TaxSettings _taxSettings;
    protected readonly TaxTransactionLogService _taxTransactionLogService;

    protected AvaTaxClient _serviceClient;
    protected bool _disposed;

    private static readonly char[] _separator = [','];

    #endregion

    #region Ctor

    public AvalaraTaxManager(AvalaraTaxSettings avalaraTaxSettings,
        IAddressService addressService,
        IAttributeParser<CheckoutAttribute, CheckoutAttributeValue> checkoutAttributeParser,
        ICategoryService categoryService,
        ICountryService countryService,
        ICustomerService customerService,
        IGenericAttributeService genericAttributeService,
        IGeoLookupService geoLookupService,
        ILogger logger,
        INopFileProvider fileProvider,
        IOrderService orderService,
        IOrderTotalCalculationService orderTotalCalculationService,
        IPaymentService paymentService,
        IProductAttributeService productAttributeService,
        IProductService productService,
        IRepository<GenericAttribute> genericAttributeRepository,
        IRepository<TaxCategory> taxCategoryRepository,
        IShoppingCartService shoppingCartService,
        IStateProvinceService stateProvinceService,
        IStaticCacheManager staticCacheManager,
        ITaxCategoryService taxCategoryService,
        ItemClassificationHttpClient itemClassificationHttpClient,
        ItemClassificationService itemClassificationService,
        IWorkContext workContext,
        ShippingSettings shippingSettings,
        TaxSettings taxSettings,
        TaxTransactionLogService taxTransactionLogService)
    {
        _avalaraTaxSettings = avalaraTaxSettings;
        _addressService = addressService;
        _checkoutAttributeParser = checkoutAttributeParser;
        _categoryService = categoryService;
        _countryService = countryService;
        _customerService = customerService;
        _genericAttributeService = genericAttributeService;
        _geoLookupService = geoLookupService;
        _logger = logger;
        _fileProvider = fileProvider;
        _orderService = orderService;
        _orderTotalCalculationService = orderTotalCalculationService;
        _paymentService = paymentService;
        _productAttributeService = productAttributeService;
        _productService = productService;
        _genericAttributeRepository = genericAttributeRepository;
        _taxCategoryRepository = taxCategoryRepository;
        _shoppingCartService = shoppingCartService;
        _stateProvinceService = stateProvinceService;
        _staticCacheManager = staticCacheManager;
        _taxCategoryService = taxCategoryService;
        _itemClassificationHttpClient = itemClassificationHttpClient;
        _itemClassificationService = itemClassificationService;
        _workContext = workContext;
        _shippingSettings = shippingSettings;
        _taxSettings = taxSettings;
        _taxTransactionLogService = taxTransactionLogService;
    }

    #endregion

    #region Utilities

    #region Common

    /// <summary>
    /// Event handler
    /// </summary>
    /// <param name="sender">Sender</param>
    /// <param name="args">Event args</param>
    protected async void OnCallCompleted(object sender, EventArgs args)
    {
        if (args is not AvaTaxCallEventArgs avaTaxCallEventArgs)
            return;

        var customer = await _workContext.GetCurrentCustomerAsync();

        //log request results
        await _taxTransactionLogService.InsertTaxTransactionLogAsync(new TaxTransactionLog
        {
            StatusCode = (int)avaTaxCallEventArgs.Code,
            Url = avaTaxCallEventArgs.RequestUri.ToString(),
            RequestMessage = avaTaxCallEventArgs.RequestBody,
            ResponseMessage = avaTaxCallEventArgs.ResponseString,
            CustomerId = customer.Id,
            CreatedDateUtc = DateTime.UtcNow
        });
    }

    /// <summary>
    /// Check that tax provider is configured
    /// </summary>
    /// <returns>True if it's configured; otherwise false</returns>
    protected bool IsConfigured()
    {
        return !string.IsNullOrEmpty(_avalaraTaxSettings.AccountId) && !string.IsNullOrEmpty(_avalaraTaxSettings.LicenseKey);
    }

    /// <summary>
    /// Handle function and get result
    /// </summary>
    /// <typeparam name="TResult">Result type</typeparam>
    /// <param name="function">Function</param>
    /// <param name="logErrors">Whether to log errors</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the result; error if exists
    /// </returns>
    protected async Task<(TResult Result, string Error)> HandleFunctionAsync<TResult>(Func<Task<TResult>> function, bool logErrors = true)
    {
        try
        {
            //ensure that Avalara tax provider is configured
            if (!IsConfigured())
                throw new NopException("Tax provider is not configured");

            var result = await function();

            return (result, default);
        }
        catch (Exception exception)
        {
            if (!logErrors)
                return (default, exception.Message);

            //compose an error message
            var errorMessage = exception.Message;
            if (exception is AvaTaxError avaTaxError && avaTaxError.error != null)
            {
                var errorInfo = avaTaxError.error.error;
                if (errorInfo != null)
                {
                    errorMessage = $"{errorInfo.code} - {errorInfo.message}{Environment.NewLine}";
                    if (errorInfo.details?.Any() ?? false)
                    {
                        var errorDetails = errorInfo.details.Aggregate(string.Empty, (error, detail) => $"{error}{detail.description}{Environment.NewLine}");
                        errorMessage = $"{errorMessage} Details: {errorDetails}";
                    }
                }
            }

            //log errors
            await _logger.ErrorAsync($"{AvalaraTaxDefaults.SystemName} error. {errorMessage}", exception, await _workContext.GetCurrentCustomerAsync());

            return (default, errorMessage);
        }
    }

    #endregion

    #region Tax calculation

    /// <summary>
    /// Create tax transaction
    /// </summary>
    /// <param name="model">Transaction details</param>
    /// <returns>Created transaction</returns>
    protected TransactionModel CreateTransaction(CreateTransactionModel model)
    {
        var transaction = ServiceClient.CreateTransaction(null, model)
            ?? throw new NopException("No response from the service");

        //whether there are any errors
        var errors = transaction.messages?.Where(m => !m.severity?.ToLower().Equals("success") ?? true).ToList() ?? [];

        if (!errors.Any())
            return transaction;

        var message = errors.Aggregate(string.Empty, (error, message) => $"{error}{message.summary}{Environment.NewLine}");
        throw new NopException(message);
    }

    /// <summary>
    /// Prepare model to create a tax transaction
    /// </summary>
    /// <param name="address">Tax address</param>
    /// <param name="customerCode">Customer code</param>
    /// <param name="documentType">Transaction document type</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the model
    /// </returns>
    protected async Task<CreateTransactionModel> PrepareTransactionModelAsync(Address address, string customerCode, DocumentType documentType)
    {
        var model = new CreateTransactionModel
        {
            customerCode = CommonHelper.EnsureMaximumLength(customerCode, 50),
            date = DateTime.UtcNow,
            type = documentType
        };

        //set company code
        var companyCode = !string.IsNullOrEmpty(_avalaraTaxSettings.CompanyCode) && !_avalaraTaxSettings.CompanyCode.Equals(Guid.Empty.ToString())
            ? _avalaraTaxSettings.CompanyCode
            : null;
        model.companyCode = CommonHelper.EnsureMaximumLength(companyCode, 25);

        //set tax addresses
        model.addresses = new AddressesModel();
        var originAddress = _avalaraTaxSettings.TaxOriginAddressType switch
        {
            TaxOriginAddressType.ShippingOrigin => await _addressService.GetAddressByIdAsync(_shippingSettings.ShippingOriginAddressId),
            TaxOriginAddressType.DefaultTaxAddress => await _addressService.GetAddressByIdAsync(_taxSettings.DefaultTaxAddressId),
            _ => null
        };
        var shipFromAddress = await MapAddressAsync(originAddress);
        var shipToAddress = await MapAddressAsync(address);
        if (shipFromAddress != null && shipToAddress != null)
        {
            model.addresses.shipFrom = shipFromAddress;
            model.addresses.shipTo = shipToAddress;
        }
        else
            model.addresses.singleLocation = shipToAddress ?? shipFromAddress;

        return model;
    }

    /// <summary>
    /// Prepare order addresses
    /// </summary>
    /// <param name="customer">Customer</param>
    /// <param name="order">Order</param>
    /// <param name="storeId">Store id</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    protected async Task PrepareOrderAddressesAsync(Customer customer, Order order, int storeId)
    {
        order.BillingAddressId = customer.BillingAddressId ?? 0;
        order.ShippingAddressId = customer.ShippingAddressId;
        if (_shippingSettings.AllowPickupInStore)
        {
            var pickupPoint = await _genericAttributeService
                .GetAttributeAsync<PickupPoint>(customer, NopCustomerDefaults.SelectedPickupPointAttribute, storeId);
            if (pickupPoint != null)
            {
                var country = await _countryService.GetCountryByTwoLetterIsoCodeAsync(pickupPoint.CountryCode);
                var state = await _stateProvinceService.GetStateProvinceByAbbreviationAsync(pickupPoint.StateAbbreviation, country?.Id);
                var pickupAddress = new Address
                {
                    Address1 = pickupPoint.Address,
                    City = pickupPoint.City,
                    CountryId = country?.Id,
                    StateProvinceId = state?.Id,
                    ZipPostalCode = pickupPoint.ZipPostalCode,
                    CreatedOnUtc = DateTime.UtcNow,
                };
                await _addressService.InsertAddressAsync(pickupAddress);
                order.PickupAddressId = pickupAddress.Id;
            }
        }
    }

    /// <summary>
    /// Get a tax address of the passed order
    /// </summary>
    /// <param name="order">Order</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the address
    /// </returns>
    protected async Task<Address> GetTaxAddressAsync(Order order)
    {
        Address address = null;

        //tax is based on billing address
        if (_taxSettings.TaxBasedOn == TaxBasedOn.BillingAddress &&
            await _addressService.GetAddressByIdAsync(order.BillingAddressId) is Address billingAddress)
        {
            address = billingAddress;
        }

        //tax is based on shipping address
        if (_taxSettings.TaxBasedOn == TaxBasedOn.ShippingAddress &&
            order.ShippingAddressId.HasValue &&
            await _addressService.GetAddressByIdAsync(order.ShippingAddressId.Value) is Address shippingAddress)
        {
            address = shippingAddress;
        }

        //tax is based on pickup point address
        if (_taxSettings.TaxBasedOnPickupPointAddress &&
            order.PickupAddressId.HasValue &&
            await _addressService.GetAddressByIdAsync(order.PickupAddressId.Value) is Address pickupAddress)
        {
            address = pickupAddress;
        }

        //or use default address for tax calculation
        address ??= await _addressService.GetAddressByIdAsync(_taxSettings.DefaultTaxAddressId);

        return address;
    }

    /// <summary>
    /// Map address model
    /// </summary>
    /// <param name="address">Address</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the address model
    /// </returns>
    protected async Task<AddressLocationInfo> MapAddressAsync(Address address)
    {
        return address == null ? null : new AddressLocationInfo
        {
            city = CommonHelper.EnsureMaximumLength(address.City, 50),
            country = CommonHelper.EnsureMaximumLength((await _countryService.GetCountryByAddressAsync(address))?.TwoLetterIsoCode, 2),
            line1 = CommonHelper.EnsureMaximumLength(address.Address1, 50),
            line2 = CommonHelper.EnsureMaximumLength(address.Address2, 100),
            postalCode = CommonHelper.EnsureMaximumLength(address.ZipPostalCode, 11),
            region = CommonHelper.EnsureMaximumLength((await _stateProvinceService.GetStateProvinceByAddressAsync(address))?.Abbreviation, 3)
        };
    }

    /// <summary>
    /// Get item lines to create tax transaction
    /// </summary>
    /// <param name="order">Order</param>
    /// <param name="orderItems">Order items</param>
    /// <param name="countryId">Destination country</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the list of item lines
    /// </returns>
    protected async Task<List<LineItemModel>> GetItemLinesAsync(Order order, IList<OrderItem> orderItems, int? countryId)
    {
        //get purchased products details
        var items = await CreateLinesForOrderItemsAsync(order, orderItems, countryId);

        //set payment method additional fee as the separate item line
        if (order.PaymentMethodAdditionalFeeExclTax > decimal.Zero)
            items.Add(await CreateLineForPaymentMethodAsync(order));

        //set shipping rate as the separate item line
        if (order.OrderShippingExclTax > decimal.Zero)
            items.Add(await CreateLineForShippingAsync(order));

        //set checkout attributes as the separate item lines
        if (!string.IsNullOrEmpty(order.CheckoutAttributesXml))
            items.AddRange(await CreateLinesForCheckoutAttributesAsync(order));

        return items;
    }

    /// <summary>
    /// Create item lines for purchased order items
    /// </summary>
    /// <param name="order">Order</param>
    /// <param name="orderItems">Order items</param>
    /// <param name="countryId">Destination country</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the collection of item lines
    /// </returns>
    protected async Task<List<LineItemModel>> CreateLinesForOrderItemsAsync(Order order, IList<OrderItem> orderItems, int? countryId)
    {
        var itemsClassification = countryId > 0
            ? (await _itemClassificationService.GetItemClassificationAsync(countryId)).ToList()
            : new();

        return await orderItems.SelectAwait(async orderItem =>
        {
            var product = await _productService.GetProductByIdAsync(orderItem.ProductId);

            var item = new LineItemModel
            {
                amount = orderItem.PriceExclTax,

                //set name as item description to avoid long values
                description = CommonHelper.EnsureMaximumLength(product?.Name, 2096),

                //whether the discount to the item was applied
                discounted = order.OrderSubTotalDiscountExclTax > decimal.Zero,

                //product exemption
                exemptionCode = product?.IsTaxExempt ?? false
                    ? CommonHelper.EnsureMaximumLength($"Exempt-product-#{product.Id}", 25)
                    : string.Empty,

                //set SKU as item code
                itemCode = product != null
                    ? CommonHelper.EnsureMaximumLength(await _productService.FormatSkuAsync(product, orderItem.AttributesXml), 50)
                    : string.Empty,

                quantity = orderItem.Quantity
            };

            //set tax code
            var productTaxCategory = await _taxCategoryService.GetTaxCategoryByIdAsync(product?.TaxCategoryId ?? 0);
            item.taxCode = CommonHelper.EnsureMaximumLength(productTaxCategory?.Name, 25);

            //whether entity use code is set
            var entityUseCode = product != null
                ? await _genericAttributeService.GetAttributeAsync<string>(product, AvalaraTaxDefaults.EntityUseCodeAttribute)
                : string.Empty;
            item.customerUsageType = CommonHelper.EnsureMaximumLength(entityUseCode, 25);

            //set HS code
            item.hsCode = itemsClassification.FirstOrDefault(x => x.ProductId == orderItem.ProductId)?.HSCode;

            return item;
        }).ToListAsync();
    }

    /// <summary>
    /// Create a separate item line for the order payment method additional fee
    /// </summary>
    /// <param name="order">Order</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the item line
    /// </returns>
    protected async Task<LineItemModel> CreateLineForPaymentMethodAsync(Order order)
    {
        var paymentItem = new LineItemModel
        {
            amount = order.PaymentMethodAdditionalFeeExclTax,

            //item description
            description = "Payment method additional fee",

            //set payment method system name as item code
            itemCode = CommonHelper.EnsureMaximumLength(order.PaymentMethodSystemName, 50),

            quantity = 1
        };

        //whether payment is taxable
        if (_taxSettings.PaymentMethodAdditionalFeeIsTaxable)
        {
            //try to get tax code
            var paymentTaxCategory = await _taxCategoryService.GetTaxCategoryByIdAsync(_taxSettings.PaymentMethodAdditionalFeeTaxClassId);
            paymentItem.taxCode = CommonHelper.EnsureMaximumLength(paymentTaxCategory?.Name, 25);
        }
        else
        {
            //if payment is non-taxable, set it as exempt
            paymentItem.exemptionCode = "Payment-fee-non-taxable";
        }

        return paymentItem;
    }

    /// <summary>
    /// Create a separate item line for the order shipping charge
    /// </summary>
    /// <param name="order">Order</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the item line
    /// </returns>
    protected async Task<LineItemModel> CreateLineForShippingAsync(Order order)
    {
        var shippingItem = new LineItemModel
        {
            amount = order.OrderShippingExclTax,

            //item description
            description = "Shipping rate",

            //set shipping method name as item code
            itemCode = CommonHelper.EnsureMaximumLength(order.ShippingMethod, 50),

            quantity = 1
        };

        //whether shipping is taxable
        if (_taxSettings.ShippingIsTaxable)
        {
            //try to get tax code
            var shippingTaxCategory = await _taxCategoryService.GetTaxCategoryByIdAsync(_taxSettings.ShippingTaxClassId);
            shippingItem.taxCode = CommonHelper.EnsureMaximumLength(shippingTaxCategory?.Name, 25);
        }
        else
        {
            //if shipping is non-taxable, set it as exempt
            shippingItem.exemptionCode = "Shipping-rate-non-taxable";
        }

        return shippingItem;
    }

    /// <summary>
    /// Create item lines for order checkout attributes
    /// </summary>
    /// <param name="order">Order</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the collection of item lines
    /// </returns>
    protected async Task<IEnumerable<LineItemModel>> CreateLinesForCheckoutAttributesAsync(Order order)
    {
        //get checkout attributes values
        var attributeValues = _checkoutAttributeParser.ParseAttributeValues(order.CheckoutAttributesXml);
        return await attributeValues.SelectManyAwait(async attributeWithValues =>
        {
            var attribute = attributeWithValues.attribute;
            return (await attributeWithValues.values.SelectAwait(async value =>
            {
                //create line
                var checkoutAttributeItem = new LineItemModel
                {
                    amount = value.PriceAdjustment,

                    //item description
                    description = CommonHelper.EnsureMaximumLength($"{attribute.Name} ({value.Name})", 2096),

                    //whether the discount to the item was applied
                    discounted = order.OrderSubTotalDiscountExclTax > decimal.Zero,

                    //set checkout attribute name and value as item code
                    itemCode = CommonHelper.EnsureMaximumLength($"{attribute.Name}-{value.Name}", 50),

                    quantity = 1
                };

                //whether checkout attribute is tax exempt
                if (attribute.IsTaxExempt)
                    checkoutAttributeItem.exemptionCode = "Attribute-non-taxable";
                else
                {
                    //or try to get tax code
                    var attributeTaxCategory = await _taxCategoryService.GetTaxCategoryByIdAsync(attribute.TaxCategoryId);
                    checkoutAttributeItem.taxCode = CommonHelper.EnsureMaximumLength(attributeTaxCategory?.Name, 25);
                }

                //whether entity use code is set
                var entityUseCode = await _genericAttributeService.GetAttributeAsync<string>(attribute, AvalaraTaxDefaults.EntityUseCodeAttribute);
                checkoutAttributeItem.customerUsageType = CommonHelper.EnsureMaximumLength(entityUseCode, 25);

                return checkoutAttributeItem;
            }).ToListAsync()).ToAsyncEnumerable();
        }).ToListAsync();
    }

    /// <summary>
    /// Prepare model tax exemption details
    /// </summary>
    /// <param name="model">Model</param>
    /// <param name="customer">Customer</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the model
    /// </returns>
    protected async Task<CreateTransactionModel> PrepareModelTaxExemptionAsync(CreateTransactionModel model, Customer customer)
    {
        if (customer.IsTaxExempt)
            model.exemptionNo = CommonHelper.EnsureMaximumLength($"Exempt-customer-#{customer.Id}", 25);
        else
        {
            var customerRole = (await _customerService.GetCustomerRolesAsync(customer)).FirstOrDefault(role => role.TaxExempt);
            if (customerRole != null)
                model.exemptionNo = CommonHelper.EnsureMaximumLength($"Exempt-{customerRole.Name}", 25);
        }

        var entityUseCode = await _genericAttributeService.GetAttributeAsync<string>(customer, AvalaraTaxDefaults.EntityUseCodeAttribute);
        if (!string.IsNullOrEmpty(entityUseCode))
            model.customerUsageType = CommonHelper.EnsureMaximumLength(entityUseCode, 25);
        else
        {
            entityUseCode = await (await _customerService.GetCustomerRolesAsync(customer))
                .SelectAwait(async customerRole => await _genericAttributeService.GetAttributeAsync<string>(customerRole, AvalaraTaxDefaults.EntityUseCodeAttribute))
                .FirstOrDefaultAsync(code => !string.IsNullOrEmpty(code));
            model.customerUsageType = CommonHelper.EnsureMaximumLength(entityUseCode, 25);
        }

        return model;
    }

    /// <summary>
    /// Get tax rates from the file
    /// </summary>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the tax rates list
    /// </returns>
    protected async Task<List<TaxRate>> GetTaxRatesFromFileAsync()
    {
        //try to create file if doesn't exist
        var filePath = _fileProvider.MapPath(AvalaraTaxDefaults.TaxRatesFilePath);
        if (!_fileProvider.FileExists(filePath))
            await DownloadTaxRatesAsync();

        if (!_fileProvider.FileExists(filePath))
            throw new NopException($"File {AvalaraTaxDefaults.TaxRatesFilePath} not found");

        //get file lines
        var text = await _fileProvider.ReadAllTextAsync(filePath, Encoding.UTF8);
        if (string.IsNullOrEmpty(text))
            throw new NopException($"File {AvalaraTaxDefaults.TaxRatesFilePath} is empty");

        var lines = text.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
        if (!lines.Any() || lines[0].Split(',').Length < 14)
            throw new NopException($"Unsupported file {AvalaraTaxDefaults.TaxRatesFilePath} structure");

        //prepare tax rates
        var taxRates = lines.Skip(1).Select(line =>
        {
            try
            {
                var values = line.Split(',', StringSplitOptions.TrimEntries);
                return new TaxRate
                {
                    Zip = values[0], //ZIP_CODE
                    State = values[1], //STATE_ABBREV
                    County = values[2], //COUNTY_NAME
                    City = values[3], //CITY_NAME
                    StateTax = decimal.Parse(values[4], NumberStyles.Any, CultureInfo.InvariantCulture), //STATE_SALES_TAX
                    CountyTax = decimal.Parse(values[6], NumberStyles.Any, CultureInfo.InvariantCulture), //COUNTY_SALES_TAX
                    CityTax = decimal.Parse(values[8], NumberStyles.Any, CultureInfo.InvariantCulture), //CITY_SALES_TAX
                    TotalTax = decimal.Parse(values[10], NumberStyles.Any, CultureInfo.InvariantCulture), //TOTAL_SALES_TAX
                    ShippingTaxable = string.Equals(values[11], "y", StringComparison.InvariantCultureIgnoreCase), //TAX_SHIPPING_ALONE
                    ShippingAndHadlingTaxable = string.Equals(values[12], "y", StringComparison.InvariantCultureIgnoreCase) //TAX_SHIPPING_AND_HANDLING_TOGETHER
                };
            }
            catch
            {
                return null;
            }
        }).Where(taxRate => taxRate is not null).ToList();

        return taxRates;
    }

    /// <summary>
    /// Check address details before creating a transaction
    /// </summary>
    /// <param name="address">Address to check</param>
    protected void CheckAddressDetails(AddressLocationInfo address)
    {
        if (address is null)
            throw new NopException("Address not set");

        //for international transactions, the two digit ISO country code is required to determine tax jurisdictions
        if (!string.IsNullOrEmpty(address.country) &&
            !string.Equals(address.country, "US", StringComparison.InvariantCultureIgnoreCase) &&
            !string.Equals(address.country, "CA", StringComparison.InvariantCultureIgnoreCase))
        {
            return;
        }

        //for US and Canadian addresses a postal code is required
        if (!string.IsNullOrEmpty(address.postalCode))
            return;

        //however a full address (street address, city, state, and zip code) will return the best tax calculation
        if (!string.IsNullOrEmpty(address.line1) && !string.IsNullOrEmpty(address.city) && !string.IsNullOrEmpty(address.region))
            return;

        throw new NopException("Address is incomplete");
    }

    #endregion

    #region Certificates

    /// <summary>
    /// Create or update the passed customer for the company
    /// </summary>
    /// <param name="customer">Customer</param>
    /// <param name="companyId">Selected company id</param>
    /// <param name="customerExists">Whether the customer is already created</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the customer details
    /// </returns>
    protected async Task<CustomerModel> CreateOrUpdateCustomerAsync(Customer customer, int companyId, bool customerExists)
    {
        var defaultAddress = new Address
        {
            Address1 = customer.StreetAddress,
            Address2 = customer.StreetAddress2,
            City = customer.City,
            ZipPostalCode = customer.ZipPostalCode,
            StateProvinceId = customer.StateProvinceId,
            CountryId = customer.CountryId
        };
        var address = await MapAddressAsync(defaultAddress);
        var model = new CustomerModel
        {
            companyId = companyId,
            customerCode = customer.Id.ToString(),
            alternateId = customer.CustomerGuid.ToString().ToLowerInvariant(),
            name = await _customerService.GetCustomerFullNameAsync(customer),
            emailAddress = customer.Email,
            line1 = address.line1,
            line2 = address.line2,
            city = address.city,
            postalCode = address.postalCode,
            country = address.country,
            region = address.region
        };

        var customerDetails = customerExists
            ? await ServiceClient.UpdateCustomerAsync(companyId, customer.Id.ToString(), model)
            : (await ServiceClient.CreateCustomersAsync(companyId, [model]))?.FirstOrDefault();

        return customerDetails;
    }

    #endregion

    #region Item classification

    /// <summary>
    /// Create classification model
    /// </summary>
    /// <param name="item">Item classification</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the classification model
    /// </returns>
    protected async Task<HSClassificationModel> CreateHSClassificationAsync(ItemClassification item)
    {
        var model = new HSClassificationModel();
        var itemModel = new ItemClassificationModel();
        var product = await _productService.GetProductByIdAsync(item.ProductId);

        model.CountryOfDestination = (await _countryService.GetCountryByIdAsync(item.CountryId))?.TwoLetterIsoCode;
        model.Id = $"{product.Id}-{model.CountryOfDestination}";

        itemModel.CompanyId = _avalaraTaxSettings.CompanyId ?? 0;
        itemModel.ItemCode = product.Sku;
        itemModel.Description = $"{product.Name}. {product.ShortDescription}";
        itemModel.ParentCode = product.ParentGroupedProductId > 0 ? product.ParentGroupedProductId.ToString() : "";
        itemModel.Summary = product.FullDescription;

        //If the product belongs to several categories, then we will ignore it and take only the first one from the list,
        //since there is no way to transfer the entire list of categories where the product is included
        var productCategories = await _categoryService.GetProductCategoriesByProductIdAsync(item.ProductId);
        var category = await _categoryService.GetCategoryByIdAsync(productCategories[0].CategoryId);
        itemModel.ItemGroup = await _categoryService.GetFormattedBreadCrumbAsync(category, separator: ">");

        model.Item = itemModel;

        return model;
    }

    #endregion

    #endregion

    #region Methods

    #region Configuration

    /// <summary>
    /// Ping service (test conection)
    /// </summary>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the ping result
    /// </returns>
    public async Task<PingResultModel> PingAsync()
    {
        return (await HandleFunctionAsync(() =>
            Task.FromResult(ServiceClient.Ping() ?? throw new NopException("No response from the service")))).Result;
    }

    /// <summary>
    /// Get account companies
    /// </summary>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the list of companies
    /// </returns>
    public async Task<List<CompanyModel>> GetAccountCompaniesAsync()
    {
        return (await HandleFunctionAsync(() =>
        {
            var result = ServiceClient.QueryCompanies(null, null, null, null, null)
                ?? throw new NopException("No response from the service");

            return Task.FromResult(result.value);
        })).Result;
    }

    /// <summary>
    /// Get pre-defined entity use codes
    /// </summary>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the list of entity use codes
    /// </returns>
    public async Task<List<EntityUseCodeModel>> GetEntityUseCodesAsync()
    {
        return (await HandleFunctionAsync(() =>
        {
            var result = ServiceClient.ListEntityUseCodes(null, null, null, null)
                ?? throw new NopException("No response from the service");

            return Task.FromResult(result.value);
        })).Result;
    }

    /// <summary>
    /// Get pre-defined tax code types
    /// </summary>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the key-value pairs of tax code types
    /// </returns>
    public async Task<Dictionary<string, string>> GetTaxCodeTypesAsync()
    {
        return (await HandleFunctionAsync(() =>
        {
            var result = ServiceClient.ListTaxCodeTypes(null, null)
                ?? throw new NopException("No response from the service");

            return Task.FromResult(result.types);
        })).Result;
    }

    /// <summary>
    /// Import tax codes from Avalara services
    /// </summary>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the number of imported tax codes; null in case of error
    /// </returns>
    public async Task<int?> ImportTaxCodesAsync()
    {
        return (await HandleFunctionAsync<int?>(async () =>
        {
            //get Avalara pre-defined system tax codes (only active)
            var systemTaxCodes = await ServiceClient.ListTaxCodesAsync("isActive eq true", null, null, null)
                ?? throw new NopException("No response from the service");

            if (!systemTaxCodes.value?.Any() ?? true)
                return null;

            //get existing tax categories
            var existingTaxCategories = (await _taxCategoryService.GetAllTaxCategoriesAsync())
                .Select(taxCategory => taxCategory.Name)
                .ToList();

            //remove duplicates
            var taxCodesToImport = systemTaxCodes.value
                .Where(taxCode => !string.IsNullOrEmpty(taxCode?.taxCode) && !existingTaxCategories.Contains(taxCode.taxCode))
                .ToList();

            var importedTaxCodesNumber = 0;
            foreach (var taxCode in taxCodesToImport)
            {
                //create new tax category
                var taxCategory = new TaxCategory { Name = taxCode.taxCode };
                await _taxCategoryService.InsertTaxCategoryAsync(taxCategory);

                //save description and type
                if (!string.IsNullOrEmpty(taxCode.description))
                    await _genericAttributeService.SaveAttributeAsync(taxCategory, AvalaraTaxDefaults.TaxCodeDescriptionAttribute, taxCode.description);
                if (!string.IsNullOrEmpty(taxCode.taxCodeTypeId))
                    await _genericAttributeService.SaveAttributeAsync(taxCategory, AvalaraTaxDefaults.TaxCodeTypeAttribute, taxCode.taxCodeTypeId);

                importedTaxCodesNumber++;
            }

            return importedTaxCodesNumber;
        })).Result;
    }

    /// <summary>
    /// Export current tax codes to Avalara services
    /// </summary>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the number of exported tax codes; null in case of error
    /// </returns>
    public async Task<int?> ExportTaxCodesAsync()
    {
        return (await HandleFunctionAsync<int?>(async () =>
        {
            if (string.IsNullOrEmpty(_avalaraTaxSettings.CompanyCode) || _avalaraTaxSettings.CompanyCode.Equals(Guid.Empty.ToString()))
                throw new NopException("Company not selected");

            //get selected company
            var selectedCompany = (await GetAccountCompaniesAsync())
                ?.FirstOrDefault(company => _avalaraTaxSettings.CompanyCode.Equals(company?.companyCode))
                ?? throw new NopException("Failed to retrieve company");

            //get existing tax codes (only active)
            var taxCodes = await ServiceClient.ListTaxCodesByCompanyAsync(selectedCompany.id, "isActive eq true", null, null, null, null)
                ?? throw new NopException("No response from the service");

            var existingTaxCodes = taxCodes.value?.Select(taxCode => taxCode.taxCode).ToList() ?? new List<string>();

            //prepare tax codes to export
            var taxCodesToExport = await (await _taxCategoryService.GetAllTaxCategoriesAsync()).SelectAwait(async taxCategory => new TaxCodeModel
            {
                createdDate = DateTime.UtcNow,
                description = CommonHelper.EnsureMaximumLength(taxCategory.Name, 255),
                isActive = true,
                taxCode = CommonHelper.EnsureMaximumLength(taxCategory.Name, 25),
                taxCodeTypeId = CommonHelper.EnsureMaximumLength(await _genericAttributeService
                    .GetAttributeAsync<string>(taxCategory, AvalaraTaxDefaults.TaxCodeTypeAttribute) ?? "P", 2)
            }).Where(taxCode => !string.IsNullOrEmpty(taxCode.taxCode)).ToListAsync();

            //add Avalara pre-defined system tax codes
            var systemTaxCodesResult = await ServiceClient.ListTaxCodesAsync("isActive eq true", null, null, null)
                ?? throw new NopException("No response from the service");

            var systemTaxCodes = systemTaxCodesResult.value?.Select(taxCode => taxCode.taxCode).ToList() ?? new List<string>();
            existingTaxCodes.AddRange(systemTaxCodes);

            //remove duplicates
            taxCodesToExport = taxCodesToExport.Where(taxCode => !existingTaxCodes.Contains(taxCode.taxCode)).Distinct().ToList();

            //export tax codes
            if (!taxCodesToExport.Any())
                return 0;

            //create items and get the result
            var createdTaxCodes = await ServiceClient.CreateTaxCodesAsync(selectedCompany.id, taxCodesToExport)
                ?? throw new NopException("No response from the service");

            //display results
            var result = createdTaxCodes?.Count;
            if (result.HasValue && result > 0)
                return result.Value;

            return null;
        })).Result;
    }

    /// <summary>
    /// Delete pre-defined system tax codes
    /// </summary>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the result
    /// </returns>
    public async Task<bool> DeleteSystemTaxCodesAsync()
    {
        return (await HandleFunctionAsync(async () =>
        {
            //get Avalara pre-defined system tax codes (only active)
            var systemTaxCodesResult = await ServiceClient.ListTaxCodesAsync("isActive eq true", null, null, null)
                ?? throw new NopException("No response from the service");

            var systemTaxCodes = systemTaxCodesResult.value?.Select(taxCode => taxCode.taxCode).ToList();
            if (!systemTaxCodes?.Any() ?? true)
                return false;

            //prepare tax categories to delete
            var categoriesIds = await _taxCategoryRepository.Table
                .Where(taxCategory => systemTaxCodes.Contains(taxCategory.Name))
                .Select(taxCategory => taxCategory.Id)
                .ToListAsync();

            //delete tax categories
            await _taxCategoryRepository.DeleteAsync(taxCategory => categoriesIds.Contains(taxCategory.Id));
            await _staticCacheManager.RemoveByPrefixAsync(NopEntityCacheDefaults<TaxCategory>.Prefix);

            //delete generic attributes
            await _genericAttributeRepository
                .DeleteAsync(attribute => attribute.KeyGroup == nameof(TaxCategory) && categoriesIds.Contains(attribute.EntityId));

            return true;
        })).Result;
    }

    /// <summary>
    /// Delete generic attributes used in the plugin
    /// </summary>
    /// <returns>A task that represents the asynchronous operation</returns>
    public async Task DeleteAttributesAsync()
    {
        await DeleteSystemTaxCodesAsync();

        await _genericAttributeRepository.DeleteAsync(attribute =>
            attribute.Key == AvalaraTaxDefaults.EntityUseCodeAttribute ||
            attribute.Key == AvalaraTaxDefaults.TaxCodeTypeAttribute ||
            attribute.Key == AvalaraTaxDefaults.TaxCodeDescriptionAttribute);
    }

    /// <summary>
    /// Export items (products) with the passed ids to Avalara services
    /// </summary>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the number of exported items; null in case of error
    /// </returns>
    public async Task<int?> ExportProductsAsync(string selectedIds)
    {
        return (await HandleFunctionAsync<int?>(async () =>
        {
            if (string.IsNullOrEmpty(_avalaraTaxSettings.CompanyCode) || _avalaraTaxSettings.CompanyCode.Equals(Guid.Empty.ToString()))
                throw new NopException("Company not selected");

            //get selected company
            var selectedCompany = (await GetAccountCompaniesAsync())
                ?.FirstOrDefault(company => _avalaraTaxSettings.CompanyCode.Equals(company?.companyCode))
                ?? throw new NopException("Failed to retrieve company");

            //get existing items
            var items = await ServiceClient.ListItemsByCompanyAsync(selectedCompany.id, null, null, null, null, null, null)
                ?? throw new NopException("No response from the service");

            //return the paginated and filtered list
            var existingItemCodes = items.value?.Select(item => item.itemCode).ToList() ?? new List<string>();

            //prepare exported items
            var productIds = selectedIds?.Split(_separator, StringSplitOptions.RemoveEmptyEntries).Select(id => Convert.ToInt32(id)).ToArray();
            var exportedItems = new List<ItemModel>();
            foreach (var product in await _productService.GetProductsByIdsAsync(productIds))
            {
                //find product combinations
                var combinations = (await _productAttributeService.GetAllProductAttributeCombinationsAsync(product.Id))
                    .Where(combination => !string.IsNullOrEmpty(combination.Sku));

                //export items with specified SKU only
                if (string.IsNullOrEmpty(product.Sku) && !combinations.Any())
                    continue;

                //prepare common properties
                var taxCategory = await _taxCategoryService.GetTaxCategoryByIdAsync(product.TaxCategoryId);
                var taxCode = CommonHelper.EnsureMaximumLength(taxCategory?.Name, 25);
                var description = CommonHelper.EnsureMaximumLength(product.Name, 255);

                //add the product as exported item
                if (!string.IsNullOrEmpty(product.Sku))
                {
                    exportedItems.Add(new ItemModel
                    {
                        createdDate = DateTime.UtcNow,
                        description = description,
                        itemCode = CommonHelper.EnsureMaximumLength(product.Sku, 50),
                        taxCode = taxCode
                    });
                }

                //add product combinations
                exportedItems.AddRange(combinations.Select(combination => new ItemModel
                {
                    createdDate = DateTime.UtcNow,
                    description = description,
                    itemCode = CommonHelper.EnsureMaximumLength(combination.Sku, 50),
                    taxCode = taxCode
                }));
            }

            //remove duplicates
            exportedItems = exportedItems.Where(item => !existingItemCodes.Contains(item.itemCode)).Distinct().ToList();

            //export items
            if (!exportedItems.Any())
                return 0;

            //create items and get the result
            var createdItems = await ServiceClient.CreateItemsAsync(selectedCompany.id, exportedItems)
                ?? throw new NopException("No response from the service");

            //display results
            var result = createdItems?.Count;
            if (result.HasValue && result > 0)
                return result.Value;

            return null;
        })).Result;
    }

    #endregion

    #region Validation

    /// <summary>
    /// Resolve the passed address against Avalara's address-validation system
    /// </summary>
    /// <param name="address">Address to validate</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the validated address
    /// </returns>
    public async Task<AddressResolutionModel> ValidateAddressAsync(Address address)
    {
        return (await HandleFunctionAsync(async () => await ServiceClient.ResolveAddressPostAsync(new AddressValidationInfo
        {
            city = CommonHelper.EnsureMaximumLength(address.City, 50),
            country = CommonHelper.EnsureMaximumLength((await _countryService.GetCountryByAddressAsync(address))?.TwoLetterIsoCode, 2),
            line1 = CommonHelper.EnsureMaximumLength(address.Address1, 50),
            line2 = CommonHelper.EnsureMaximumLength(address.Address2, 100),
            postalCode = CommonHelper.EnsureMaximumLength(address.ZipPostalCode, 11),
            region = CommonHelper.EnsureMaximumLength((await _stateProvinceService.GetStateProvinceByAddressAsync(address))?.Abbreviation, 3),
            textCase = TextCase.Mixed
        }) ?? throw new NopException("No response from the service"), false)).Result;
    }

    #endregion

    #region Tax calculation

    /// <summary>
    /// Create test tax transaction
    /// </summary>
    /// <param name="address">Tax address</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the transaction
    /// </returns>
    public async Task<TransactionModel> CreateTestTaxTransactionAsync(Address address)
    {
        return (await HandleFunctionAsync(async () =>
        {
            if (_avalaraTaxSettings.UseTaxRateTables)
            {
                var taxRates = await GetTaxRatesFromFileAsync();
                var taxRate = taxRates.FirstOrDefault(record => address.ZipPostalCode?.StartsWith(record.Zip) ?? false);
                if (taxRate?.TotalTax is null)
                    throw new NopException($"No rate found for zip code {address.ZipPostalCode}");

                var summary = new List<TransactionSummary>();
                if (!string.IsNullOrEmpty(taxRate.State))
                    summary.Add(new() { jurisName = taxRate.State, rate = taxRate.StateTax });
                if (!string.IsNullOrEmpty(taxRate.County))
                    summary.Add(new() { jurisName = taxRate.County, rate = taxRate.CountyTax });
                if (!string.IsNullOrEmpty(taxRate.City))
                    summary.Add(new() { jurisName = taxRate.City, rate = taxRate.CityTax });
                return new TransactionModel { totalTax = taxRate.TotalTax * 100, summary = summary };
            }

            var customer = await _workContext.GetCurrentCustomerAsync();
            //create tax transaction for a simplified item and without saving 
            var model = await PrepareTransactionModelAsync(address, customer.Id.ToString(), DocumentType.SalesOrder);
            model.lines = [new LineItemModel { amount = 100, quantity = 1 }];
            return CreateTransaction(model);
        }, false)).Result;
    }

    /// <summary>
    /// Get tax rate by request
    /// </summary>
    /// <param name="taxRateRequest">Tax rate request</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the tax rate; error if exists
    /// </returns>
    public async Task<(decimal TaxRate, string Error)> GetTaxRateAsync(TaxRateRequest taxRateRequest)
    {
        if (_avalaraTaxSettings.UseTaxRateTables)
        {
            if (string.IsNullOrEmpty(taxRateRequest.Address.ZipPostalCode))
                return (decimal.Zero, "Zip/postal code not set");

            var key = _staticCacheManager
                .PrepareKeyForDefaultCache(AvalaraTaxDefaults.TaxRateByZipCacheKey, taxRateRequest.Address.ZipPostalCode);
            var taxRateByZip = await _staticCacheManager.GetAsync(key, async () => (await HandleFunctionAsync(async () =>
            {
                var taxRates = await GetTaxRatesFromFileAsync();
                var taxRate = taxRates.FirstOrDefault(record => taxRateRequest.Address.ZipPostalCode.StartsWith(record.Zip));
                return taxRate?.TotalTax * 100;
            }, false)).Result);
            if (taxRateByZip is null)
                return (decimal.Zero, "Tax rate not found for the specified address");

            return (taxRateByZip.Value, null);
        }

        //prepare cache key
        var address = await _addressService.GetAddressByIdAsync(taxRateRequest.Address.Id);
        var customer = taxRateRequest.Customer ?? await _workContext.GetCurrentCustomerAsync();
        var taxCategoryId = taxRateRequest.TaxCategoryId > 0
            ? taxRateRequest.TaxCategoryId
            : taxRateRequest.Product?.TaxCategoryId ?? 0;
        var cacheKey = _staticCacheManager.PrepareKeyForDefaultCache(AvalaraTaxDefaults.TaxRateCacheKey,
            _avalaraTaxSettings.GetTaxRateByAddressOnly ? null : customer,
            _avalaraTaxSettings.GetTaxRateByAddressOnly ? 0 : taxCategoryId,
            taxRateRequest.Address.Address1,
            taxRateRequest.Address.City,
            taxRateRequest.Address.StateProvinceId ?? 0,
            taxRateRequest.Address.CountryId ?? 0,
            taxRateRequest.Address.ZipPostalCode);
        if (_avalaraTaxSettings.GetTaxRateByAddressOnly && _avalaraTaxSettings.TaxRateByAddressCacheTime > 0)
            cacheKey.CacheTime = _avalaraTaxSettings.TaxRateByAddressCacheTime;

        //try to get previously cached tax rate
        var taxRate = await _staticCacheManager.GetAsync<decimal?>(cacheKey);
        if (taxRate is not null)
            return (taxRate.Value, null);

        //or specify it now
        (taxRate, var error) = await HandleFunctionAsync(async () =>
        {
            //create tax transaction for a single item and without saving
            var model = await PrepareTransactionModelAsync(address, customer.Id.ToString(), DocumentType.SalesOrder);

            //check some address details before creating a transaction
            CheckAddressDetails(model.addresses?.shipTo);

            var taxCategory = await _taxCategoryService.GetTaxCategoryByIdAsync(taxCategoryId);
            model.lines =
            [
                new ()
                {
                    amount = 100,
                    quantity = 1,
                    itemCode = CommonHelper.EnsureMaximumLength(taxRateRequest.Product?.Sku, 50),
                    taxCode = CommonHelper.EnsureMaximumLength(taxCategory?.Name, 25),
                    exemptionCode = !_avalaraTaxSettings.GetTaxRateByAddressOnly && (taxRateRequest.Product?.IsTaxExempt ?? false)
                        ? CommonHelper.EnsureMaximumLength($"Exempt-product-#{taxRateRequest.Product.Id}", 25)
                        : string.Empty
                }
            ];

            //prepare tax exemption 
            if (!_avalaraTaxSettings.GetTaxRateByAddressOnly)
                await PrepareModelTaxExemptionAsync(model, customer);

            var transaction = CreateTransaction(model);

            //we return the tax total, since we used the amount of 100 when requesting, so the total is the same as the rate
            return transaction?.totalTax;
        }, false);
        if (!string.IsNullOrEmpty(error))
            return (decimal.Zero, error);

        //and cache it
        await _staticCacheManager.SetAsync(cacheKey, taxRate);

        return (taxRate ?? decimal.Zero, null);
    }

    /// <summary>
    /// Create transaction to get tax total for the passed request
    /// </summary>
    /// <param name="taxTotalRequest">Tax total request</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the transaction; error if exists
    /// </returns>
    public async Task<(TransactionModel Transaction, string Error)> CreateTaxTotalTransactionAsync(TaxTotalRequest taxTotalRequest)
    {
        return await HandleFunctionAsync(async () =>
        {
            //create dummy order to create tax transaction
            var customer = taxTotalRequest.Customer;
            var order = new Order { CustomerId = customer.Id };

            //addresses
            await PrepareOrderAddressesAsync(customer, order, taxTotalRequest.StoreId);

            //checkout attributes
            order.CheckoutAttributesXml = await _genericAttributeService
                .GetAttributeAsync<string>(customer, NopCustomerDefaults.CheckoutAttributes, taxTotalRequest.StoreId);

            //shipping method
            order.ShippingMethod = (await _genericAttributeService
                .GetAttributeAsync<ShippingOption>(customer, NopCustomerDefaults.SelectedShippingOptionAttribute, taxTotalRequest.StoreId))?.Name;
            order.OrderShippingExclTax = (await _orderTotalCalculationService.GetShoppingCartShippingTotalAsync(taxTotalRequest.ShoppingCart, false)).shippingTotal ?? 0;

            //payment method
            if (taxTotalRequest.UsePaymentMethodAdditionalFee)
            {
                order.PaymentMethodSystemName = await _genericAttributeService
                    .GetAttributeAsync<string>(customer, NopCustomerDefaults.SelectedPaymentMethodAttribute, taxTotalRequest.StoreId);
                if (!string.IsNullOrEmpty(order.PaymentMethodSystemName))
                    order.PaymentMethodAdditionalFeeExclTax = await _paymentService.GetAdditionalHandlingFeeAsync(taxTotalRequest.ShoppingCart, order.PaymentMethodSystemName);
            }

            //discount amount
            var (orderSubTotalDiscountExclTax, _, _, _, _) = await _orderTotalCalculationService.GetShoppingCartSubTotalAsync(taxTotalRequest.ShoppingCart, false);
            order.OrderSubTotalDiscountExclTax = orderSubTotalDiscountExclTax;

            //create dummy order items
            var orderItems = await taxTotalRequest.ShoppingCart.SelectAwait(async cartItem => new OrderItem
            {
                AttributesXml = cartItem.AttributesXml,
                ProductId = cartItem.ProductId,
                Quantity = cartItem.Quantity,
                PriceExclTax = (await _shoppingCartService.GetSubTotalAsync(cartItem, true)).subTotal
            }).ToListAsync();

            //prepare transaction model
            var address = await GetTaxAddressAsync(order);
            var model = await PrepareTransactionModelAsync(address, customer.Id.ToString(), DocumentType.SalesOrder);
            model.email = CommonHelper.EnsureMaximumLength(customer.Email, 50);
            model.discount = order.OrderSubTotalDiscountExclTax;

            //check some address details before creating a transaction
            CheckAddressDetails(model.addresses?.shipTo);

            //set purchased item lines
            model.lines = await GetItemLinesAsync(order, orderItems, address.CountryId);

            //set whole request tax exemption
            await PrepareModelTaxExemptionAsync(model, customer);

            var transaction = CreateTransaction(model);
            if (transaction?.totalTax is null)
                throw new NopException("No response from the service");

            return transaction;
        });
    }

    /// <summary>
    /// Create tax transaction for the placed order
    /// </summary>
    /// <param name="order">Order</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the transaction
    /// </returns>
    public async Task<TransactionModel> CreateOrderTaxTransactionAsync(Order order)
    {
        return (await HandleFunctionAsync(async () =>
        {
            //prepare transaction model
            var address = await GetTaxAddressAsync(order);
            var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId);
            var model = await PrepareTransactionModelAsync(address, customer.Id.ToString(), DocumentType.SalesInvoice);
            model.email = CommonHelper.EnsureMaximumLength(customer.Email, 50);
            model.code = CommonHelper.EnsureMaximumLength(order.CustomOrderNumber, 50);
            model.commit = _avalaraTaxSettings.CommitTransactions;
            model.discount = order.OrderSubTotalDiscountExclTax;

            //set purchased item lines
            var orderItems = await _orderService.GetOrderItemsAsync(order.Id);
            model.lines = await GetItemLinesAsync(order, orderItems, address.CountryId);

            //set whole request tax exemption
            await PrepareModelTaxExemptionAsync(model, customer);

            return CreateTransaction(model);
        })).Result;
    }

    /// <summary>
    /// Void tax transaction
    /// </summary>
    /// <param name="order">Order</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public async Task VoidTaxTransactionAsync(Order order)
    {
        await HandleFunctionAsync(() =>
        {
            if (string.IsNullOrEmpty(_avalaraTaxSettings.CompanyCode) || _avalaraTaxSettings.CompanyCode.Equals(Guid.Empty.ToString()))
                throw new NopException("Company not selected");

            var model = new VoidTransactionModel { code = VoidReasonCode.DocVoided };
            var transaction = ServiceClient.VoidTransaction(_avalaraTaxSettings.CompanyCode, order.CustomOrderNumber, null, null, model)
                ?? throw new NopException("No response from the service");

            return Task.FromResult(transaction);
        });
    }

    /// <summary>
    /// Delete tax transaction
    /// </summary>
    /// <param name="order">Order</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public async Task DeleteTaxTransactionAsync(Order order)
    {
        await HandleFunctionAsync(() =>
        {
            if (string.IsNullOrEmpty(_avalaraTaxSettings.CompanyCode) || _avalaraTaxSettings.CompanyCode.Equals(Guid.Empty.ToString()))
                throw new NopException("Company not selected");

            var model = new VoidTransactionModel { code = VoidReasonCode.DocDeleted };
            var transaction = ServiceClient.VoidTransaction(_avalaraTaxSettings.CompanyCode, order.CustomOrderNumber, null, null, model)
                ?? throw new NopException("No response from the service");

            return Task.FromResult(transaction);
        });
    }

    /// <summary>
    /// Refund tax transaction
    /// </summary>
    /// <param name="order">Order</param>
    /// <param name="amountToRefund">Amount to refund</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public async Task RefundTaxTransactionAsync(Order order, decimal amountToRefund)
    {
        await HandleFunctionAsync(() =>
        {
            if (string.IsNullOrEmpty(_avalaraTaxSettings.CompanyCode) || _avalaraTaxSettings.CompanyCode.Equals(Guid.Empty.ToString()))
                throw new NopException("Company not selected");

            //first try to get saved tax transaction
            var transaction = ServiceClient.GetTransactionByCodeAndType(_avalaraTaxSettings.CompanyCode, order.CustomOrderNumber, DocumentType.SalesInvoice, null)
                ?? throw new NopException("No response from the service");

            //create refund transaction model
            var model = new RefundTransactionModel
            {
                referenceCode = CommonHelper.EnsureMaximumLength(transaction.code, 50),
                refundDate = transaction.date ?? DateTime.UtcNow,
                refundType = RefundType.Full
            };

            //whether it's a partial refund
            var isPartialRefund = amountToRefund < order.OrderTotal;
            if (isPartialRefund)
            {
                model.refundType = RefundType.Percentage;
                model.refundPercentage = amountToRefund / (order.OrderTotal - order.OrderTax) * 100;
            }

            transaction = ServiceClient.RefundTransaction(_avalaraTaxSettings.CompanyCode, transaction.code, null, null, null, model)
                ?? throw new NopException("No response from the service");

            return Task.FromResult(transaction);
        });
    }

    /// <summary>
    /// Download a file listing tax rates by postal code
    /// </summary>
    /// <returns>A task that represents the asynchronous operation</returns>
    public async Task DownloadTaxRatesAsync()
    {
        await HandleFunctionAsync(async () =>
        {
            var file = ServiceClient.DownloadTaxRatesByZipCode(DateTime.UtcNow, null)
                ?? throw new NopException("No response from the service");

            var filePath = _fileProvider.MapPath(AvalaraTaxDefaults.TaxRatesFilePath);
            await _fileProvider.WriteAllBytesAsync(filePath, file.Data);

            return true;
        });
    }

    #endregion

    #region Certificates

    /// <summary>
    /// Checks whether the company is configured to use exemption certificates
    /// </summary>
    /// <param name="request">Whether to request the certificate setup</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the result of check
    /// </returns>
    public async Task<bool?> GetCertificateSetupStatusAsync(bool request = false)
    {
        return (await HandleFunctionAsync<bool?>(async () =>
        {
            if (_avalaraTaxSettings.CompanyId is null)
                throw new NopException("Company not selected");

            var provisionStatus = request
                ? await ServiceClient.RequestCertificateSetupAsync(_avalaraTaxSettings.CompanyId.Value)
                : await ServiceClient.GetCertificateSetupAsync(_avalaraTaxSettings.CompanyId.Value)
                ?? throw new NopException("Failed to get certificate setup status");

            if (provisionStatus.status == CertCaptureProvisionStatus.NotProvisioned)
                return default;

            return provisionStatus.status == CertCaptureProvisionStatus.Provisioned;
        })).Result;
    }

    /// <summary>
    /// Create a new authorization token to launch the certificates services
    /// </summary>
    /// <param name="customer">Customer for which token is creating</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the generated token
    /// </returns>
    public async Task<string> CreateTokenAsync(Customer customer)
    {
        return (await HandleFunctionAsync(async () =>
        {
            if (_avalaraTaxSettings.CompanyId is null)
                throw new NopException("Company not selected");

            //no need to log the error if the customer is not created yet
            var customerExists = false;
            try
            {
                customerExists = await ServiceClient
                    .GetCustomerAsync(_avalaraTaxSettings.CompanyId.Value, customer.Id.ToString(), null) is not null;
            }
            catch { }
            if (!customerExists)
                await CreateOrUpdateCustomerAsync(customer, _avalaraTaxSettings.CompanyId.Value, customerExists);

            var model = new CreateECommerceTokenInputModel { customerNumber = customer.Id.ToString() };
            return (await ServiceClient.CreateECommerceTokenAsync(_avalaraTaxSettings.CompanyId.Value, model))?.token
                ?? throw new NopException("Failed to get token");
        })).Result;
    }

    /// <summary>
    /// Get the certificate exposure zones defined by the company
    /// </summary>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the list of exposure zones
    /// </returns>
    public async Task<List<ExposureZoneModel>> GetExposureZonesAsync()
    {
        return (await HandleFunctionAsync(async () =>
        {
            var result = await ServiceClient.ListCertificateExposureZonesAsync(null, null, null, null)
                ?? throw new NopException("Failed to get exposure zones");

            return result.value;
        })).Result;
    }

    /// <summary>
    /// Create or update the passed customer for the company
    /// </summary>
    /// <param name="customer">Customer</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the customer details
    /// </returns>
    public async Task<CustomerModel> CreateOrUpdateCustomerAsync(Customer customer)
    {
        return (await HandleFunctionAsync(async () =>
        {
            if (_avalaraTaxSettings.CompanyId is null)
                throw new NopException("Company not selected");

            //no need to log the error if the customer is not created yet
            var customerExists = false;
            try
            {
                customerExists = await ServiceClient
                    .GetCustomerAsync(_avalaraTaxSettings.CompanyId.Value, customer.Id.ToString(), null) is not null;
            }
            catch { }

            return await CreateOrUpdateCustomerAsync(customer, _avalaraTaxSettings.CompanyId.Value, customerExists)
                ?? throw new NopException("Failed to update customer details");
        })).Result;
    }

    /// <summary>
    /// Delete a customer with the passed identifier
    /// </summary>
    /// <param name="customerId">Customer id</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the customer details
    /// </returns>
    public async Task<CustomerModel> DeleteCustomerAsync(int customerId)
    {
        return (await HandleFunctionAsync(async () =>
        {
            if (_avalaraTaxSettings.CompanyId is null)
                throw new NopException("Company not selected");

            return await ServiceClient.DeleteCustomerAsync(_avalaraTaxSettings.CompanyId.Value, customerId.ToString())
                ?? throw new NopException("Failed to delete customer");
        })).Result;
    }

    /// <summary>
    /// Get valid certificates linked to a customer in a particular country and region
    /// </summary>
    /// <param name="customer">Customer</param>
    /// <param name="storeId">Current store id</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the list of certificates
    /// </returns>
    public async Task<CertificateModel> GetValidCertificatesAsync(Customer customer, int storeId)
    {
        return (await HandleFunctionAsync(async () =>
        {
            if (_avalaraTaxSettings.CompanyId is null)
                throw new NopException("Company not selected");

            //create dummy order to get selected address
            var order = new Order { CustomerId = customer.Id };
            await PrepareOrderAddressesAsync(customer, order, storeId);
            var address = await GetTaxAddressAsync(order);
            var shipTo = await MapAddressAsync(address);

            //check exemption status
            var exemptionStatus = await ServiceClient
                .ListValidCertificatesForCustomerAsync(_avalaraTaxSettings.CompanyId.Value, customer.Id.ToString(), shipTo.country, shipTo.region)
                ?? throw new NopException("Failed to get customer's certificates");

            var exempt = string.Equals(exemptionStatus.status, "Exempt", StringComparison.InvariantCultureIgnoreCase);
            return exempt ? exemptionStatus.certificate : null;
        })).Result;
    }

    /// <summary>
    /// Get all certificates linked to a customer
    /// </summary>
    /// <param name="customer">Customer</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the list of certificates
    /// </returns>
    public async Task<List<CertificateModel>> GetCustomerCertificatesAsync(Customer customer)
    {
        return (await HandleFunctionAsync(async () =>
        {
            if (_avalaraTaxSettings.CompanyId is null)
                throw new NopException("Company not selected");

            var certificates = await ServiceClient
                .ListCertificatesForCustomerAsync(_avalaraTaxSettings.CompanyId.Value, customer.Id.ToString(), null, null, null, null, null)
                ?? throw new NopException("Failed to get customer's certificates");

            return certificates.value;
        })).Result;
    }

    /// <summary>
    /// Download a PDF file for the certificate
    /// </summary>
    /// <param name="certificateId">The unique ID number of the certificate</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the file details
    /// </returns>
    public async Task<FileResult> DownloadCertificateAsync(int certificateId)
    {
        return (await HandleFunctionAsync(() =>
        {
            if (_avalaraTaxSettings.CompanyId is null)
                throw new NopException("Company not selected");

            var file = ServiceClient
                .DownloadCertificateImage(_avalaraTaxSettings.CompanyId.Value, certificateId, null, CertificatePreviewType.Pdf)
                ?? throw new NopException("Failed to download certificate");

            return Task.FromResult(file);
        })).Result;
    }

    /// <summary>
    /// Get an existing invitation to upload certificates on the external website
    /// </summary>
    /// <param name="customer">Customer</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the URL to redirect customer
    /// </returns>
    public async Task<string> GetInvitationAsync(Customer customer)
    {
        return (await HandleFunctionAsync(async () =>
        {
            if (_avalaraTaxSettings.CompanyId is null)
                throw new NopException("Company not selected");

            //create invitation for customer
            var invitationModel = new List<CreateCertExpressInvitationModel>
            {
                new() { deliveryMethod = CertificateRequestDeliveryMethod.Download }
            };
            var invitation = (await ServiceClient
                .CreateCertExpressInvitationAsync(_avalaraTaxSettings.CompanyId.Value, customer.Id.ToString(), invitationModel))
                ?.FirstOrDefault()?.invitation
                ?? throw new NopException("Failed to get invitation");

            return invitation.requestLink;
        })).Result;
    }

    #endregion

    #region Item classification

    /// <summary>
    /// Get item classification
    /// </summary>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the classification results
    /// </returns>
    public async Task<(HSClassificationModel model, string Error)> ClassificationProductsAsync(ItemClassification item)
    {
        return await HandleFunctionAsync(async () =>
        {
            if (_avalaraTaxSettings.CompanyId is null)
                throw new NopException("Company not selected");

            var companyId = _avalaraTaxSettings.CompanyId.ToString();
            var classificationModel = await CreateHSClassificationAsync(item);
            var classification = await _itemClassificationHttpClient
                .RequestAsync<CreateHSClassificationRequest, HSClassificationModel>(new CreateHSClassificationRequest(companyId)
                {
                    Id = classificationModel.Id,
                    CountryOfDestination = classificationModel.CountryOfDestination,
                    Item = classificationModel.Item
                });

            return classification;
        });
    }

    /// <summary>
    /// Handle webhook request
    /// </summary>
    /// <param name="request">HTTP request</param>
    /// <returns>
    /// A task that represents the asynchronous operation</returns>
    public async Task HandleItemClassificationWebhookAsync(Microsoft.AspNetCore.Http.HttpRequest request)
    {
        await HandleFunctionAsync(async () =>
        {
            using var streamReader = new StreamReader(request.Body);
            var requestContent = await streamReader.ReadToEndAsync();
            if (string.IsNullOrEmpty(requestContent))
                throw new NopException("Webhook request content is empty");

            //log request
            var stringBuilder = new StringBuilder();
            stringBuilder.Append(nameof(request.Headers));
            foreach (var header in request.Headers)
            {
                stringBuilder.AppendLine($"{header.Key}: {header.Value}");
            }
            stringBuilder.AppendLine($"{nameof(request.Path)}: {request.Path}");
            stringBuilder.AppendLine($"{nameof(request.QueryString)}: {request.QueryString}");
            stringBuilder.AppendLine($"{nameof(request.Body)}: {requestContent}");

            await _logger.InformationAsync($"{AvalaraTaxDefaults.SystemName} webhook request info. {stringBuilder}");

            //get webhook message
            var message = JsonConvert.DeserializeObject<HSClassificationModel>(requestContent);
            if (!string.IsNullOrEmpty(message.HsCode))
            {
                var itemClassification = await _itemClassificationService.GetItemClassificationByRequestIdAsync(message.Id);
                if (itemClassification is not null)
                {
                    itemClassification.HSCode = message.HsCode;
                    itemClassification.UpdatedOnUtc = DateTime.UtcNow;
                    await _itemClassificationService.UpdateItemClassificationAsync(itemClassification);
                }
            }

            return true;
        });
    }

    #endregion

    /// <summary>
    /// Dispose object
    /// </summary>
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    // Protected implementation of Dispose pattern.
    protected void Dispose(bool disposing)
    {
        if (_disposed)
            return;

        if (disposing)
        {
            if (_serviceClient != null)
                _serviceClient.CallCompleted -= OnCallCompleted;
        }

        _disposed = true;
    }

    #endregion

    #region Properties

    /// <summary>
    /// Gets client that connects to Avalara services
    /// </summary>
    protected AvaTaxClient ServiceClient
    {
        get
        {
            if (_serviceClient == null)
            {
                //create a client with credentials
                _serviceClient = new AvaTaxClient(AvalaraTaxDefaults.ApplicationName,
                        AvalaraTaxDefaults.ApplicationVersion, Environment.MachineName,
                        _avalaraTaxSettings.UseSandbox ? AvaTaxEnvironment.Sandbox : AvaTaxEnvironment.Production)
                    .WithSecurity(_avalaraTaxSettings.AccountId, _avalaraTaxSettings.LicenseKey);

                //invoke method after each request to services completed
                if (_avalaraTaxSettings.EnableLogging)
                    _serviceClient.CallCompleted += OnCallCompleted;
            }

            return _serviceClient;
        }
    }

    #endregion
}