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;

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

    protected readonly AvalaraTaxSettings _avalaraTaxSettings;
    protected readonly IAddressService _addressService;
    protected readonly IAttributeParser _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 _genericAttributeRepository;
    protected readonly IRepository _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 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 genericAttributeRepository,
        IRepository 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

    /// 
    /// Event handler
    /// 
    /// Sender
    /// Event args
    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
        });
    }

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

    /// 
    /// Handle function and get result with Error message in view
    /// 
    /// Result type
    /// Function
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the result
    /// 
    protected async Task<(TResult Result, string Error)> HandleFunctionExAsync(Func> function)
    {
        try
        {
            //ensure that Avalara tax provider is configured
            if (!IsConfigured())
                throw new NopException("Tax provider is not configured");

            return (await function(), default);
        }
        catch (Exception exception)
        {
            //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);
        }
    }

    /// 
    /// Handle function and get result
    /// 
    /// Result type
    /// Function
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the result
    /// 
    protected async Task HandleFunctionAsync(Func> function)
    {
        try
        {
            //ensure that Avalara tax provider is configured
            if (!IsConfigured())
                throw new NopException("Tax provider is not configured");

            return await function();
        }
        catch (Exception exception)
        {
            //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;
        }
    }

    #endregion

    #region Tax calculation

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

        //whether there are any errors
        if (transaction.messages?.Any() ?? false)
        {
            var message = transaction.messages.Aggregate(string.Empty, (error, message) => $"{error}{message.summary}{Environment.NewLine}");
            throw new NopException(message);
        }

        return transaction;
    }

    /// 
    /// Prepare model to create a tax transaction
    /// 
    /// Tax address
    /// Customer code
    /// Transaction document type
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the model
    /// 
    protected async Task 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;
    }

    /// 
    /// Prepare order addresses
    /// 
    /// Customer
    /// Order
    /// Store id
    /// A task that represents the asynchronous operation
    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(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;
            }
        }
    }

    /// 
    /// Get a tax address of the passed order
    /// 
    /// Order
    /// 
    /// A task that represents the asynchronous operation
    /// The task result contains the address
    /// 
    protected async Task
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; } /// /// Map address model /// /// Address /// /// A task that represents the asynchronous operation /// The task result contains the address model /// protected async Task 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) }; } /// /// Get item lines to create tax transaction /// /// Order /// Order items /// Destination country /// /// A task that represents the asynchronous operation /// The task result contains the list of item lines /// protected async Task> GetItemLinesAsync(Order order, IList 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; } /// /// Create item lines for purchased order items /// /// Order /// Order items /// Destination country /// /// A task that represents the asynchronous operation /// The task result contains the collection of item lines /// protected async Task> CreateLinesForOrderItemsAsync(Order order, IList 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(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(); } /// /// Create a separate item line for the order payment method additional fee /// /// Order /// /// A task that represents the asynchronous operation /// The task result contains the item line /// protected async Task 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; } /// /// Create a separate item line for the order shipping charge /// /// Order /// /// A task that represents the asynchronous operation /// The task result contains the item line /// protected async Task 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; } /// /// Create item lines for order checkout attributes /// /// Order /// /// A task that represents the asynchronous operation /// The task result contains the collection of item lines /// protected async Task> 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(attribute, AvalaraTaxDefaults.EntityUseCodeAttribute); checkoutAttributeItem.customerUsageType = CommonHelper.EnsureMaximumLength(entityUseCode, 25); return checkoutAttributeItem; }).ToListAsync()).ToAsyncEnumerable(); }).ToListAsync(); } /// /// Prepare model tax exemption details /// /// Model /// Customer /// /// A task that represents the asynchronous operation /// The task result contains the model /// protected async Task 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(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(customerRole, AvalaraTaxDefaults.EntityUseCodeAttribute)) .FirstOrDefaultAsync(code => !string.IsNullOrEmpty(code)); model.customerUsageType = CommonHelper.EnsureMaximumLength(entityUseCode, 25); } return model; } /// /// Get tax rates from the file /// /// /// A task that represents the asynchronous operation /// The task result contains the tax rates list /// protected async Task> 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; } #endregion #region Certificates /// /// Create or update the passed customer for the company /// /// Customer /// Selected company id /// Whether the customer is already created /// /// A task that represents the asynchronous operation /// The task result contains the customer details /// protected async Task 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 /// /// Create classification model /// /// Item classification /// /// A task that represents the asynchronous operation /// The task result contains the classification model /// protected async Task 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 /// /// Ping service (test conection) /// /// /// A task that represents the asynchronous operation /// The task result contains the ping result /// public async Task PingAsync() { return await HandleFunctionAsync(() => Task.FromResult(ServiceClient.Ping() ?? throw new NopException("No response from the service"))); } /// /// Get account companies /// /// /// A task that represents the asynchronous operation /// The task result contains the list of companies /// public async Task> 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); }); } /// /// Get pre-defined entity use codes /// /// /// A task that represents the asynchronous operation /// The task result contains the list of entity use codes /// public async Task> 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); }); } /// /// Get pre-defined tax code types /// /// /// A task that represents the asynchronous operation /// The task result contains the key-value pairs of tax code types /// public async Task> GetTaxCodeTypesAsync() { return await HandleFunctionAsync(() => { var result = ServiceClient.ListTaxCodeTypes(null, null) ?? throw new NopException("No response from the service"); return Task.FromResult(result.types); }); } /// /// Import tax codes from Avalara services /// /// /// A task that represents the asynchronous operation /// The task result contains the number of imported tax codes; null in case of error /// public async Task ImportTaxCodesAsync() { return await HandleFunctionAsync(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; }); } /// /// Export current tax codes to Avalara services /// /// /// A task that represents the asynchronous operation /// The task result contains the number of exported tax codes; null in case of error /// public async Task ExportTaxCodesAsync() { return await HandleFunctionAsync(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(); //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(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(); 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; }); } /// /// Delete pre-defined system tax codes /// /// /// A task that represents the asynchronous operation /// The task result contains the result /// public async Task 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.Prefix); //delete generic attributes await _genericAttributeRepository .DeleteAsync(attribute => attribute.KeyGroup == nameof(TaxCategory) && categoriesIds.Contains(attribute.EntityId)); return true; }); } /// /// Delete generic attributes used in the plugin /// /// A task that represents the asynchronous operation public async Task DeleteAttributesAsync() { await DeleteSystemTaxCodesAsync(); await _genericAttributeRepository.DeleteAsync(attribute => attribute.Key == AvalaraTaxDefaults.EntityUseCodeAttribute || attribute.Key == AvalaraTaxDefaults.TaxCodeTypeAttribute || attribute.Key == AvalaraTaxDefaults.TaxCodeDescriptionAttribute); } /// /// Export items (products) with the passed ids to Avalara services /// /// /// A task that represents the asynchronous operation /// The task result contains the number of exported items; null in case of error /// public async Task ExportProductsAsync(string selectedIds) { return await HandleFunctionAsync(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(); //prepare exported items var productIds = selectedIds?.Split(_separator, StringSplitOptions.RemoveEmptyEntries).Select(id => Convert.ToInt32(id)).ToArray(); var exportedItems = new List(); 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; }); } #endregion #region Validation /// /// Resolve the passed address against Avalara's address-validation system /// /// Address to validate /// /// A task that represents the asynchronous operation /// The task result contains the validated address /// public async Task 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"))); } #endregion #region Tax calculation /// /// Create test tax transaction /// /// Tax address /// /// A task that represents the asynchronous operation /// The task result contains the ransaction /// public async Task 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(); 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); }); } /// /// Create transaction to get tax rate /// /// Tax rate request /// /// A task that represents the asynchronous operation /// The task result contains the ransaction /// public async Task GetTaxRateAsync(TaxRateRequest taxRateRequest) { if (_avalaraTaxSettings.UseTaxRateTables) { var key = _staticCacheManager .PrepareKeyForDefaultCache(AvalaraTaxDefaults.TaxRateByZipCacheKey, taxRateRequest.Address.ZipPostalCode); return await _staticCacheManager.GetAsync(key, async () => await HandleFunctionAsync(async () => { var taxRates = await GetTaxRatesFromFileAsync(); var taxRate = taxRates.FirstOrDefault(record => taxRateRequest.Address.ZipPostalCode?.StartsWith(record.Zip) ?? false); return taxRate?.TotalTax * 100; })); } //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; //get tax rate return await _staticCacheManager.GetAsync(cacheKey, async () => { return await HandleFunctionAsync(async () => { //create tax transaction for a single item and without saving var model = await PrepareTransactionModelAsync(address, customer.Id.ToString(), DocumentType.SalesOrder); var taxCategory = await _taxCategoryService.GetTaxCategoryByIdAsync(taxCategoryId); model.lines = [ new LineItemModel { 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; }); }); } /// /// Create transaction to get tax total for the passed request /// /// Tax total request /// /// A task that represents the asynchronous operation /// The task result contains the ransaction /// public async Task 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(customer, NopCustomerDefaults.CheckoutAttributes, taxTotalRequest.StoreId); //shipping method order.ShippingMethod = (await _genericAttributeService .GetAttributeAsync(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(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; //set purchased item lines model.lines = await GetItemLinesAsync(order, orderItems, address.CountryId); //set whole request tax exemption await PrepareModelTaxExemptionAsync(model, customer); return CreateTransaction(model); }); } /// /// Create tax transaction for the placed order /// /// Order /// /// A task that represents the asynchronous operation /// The task result contains the ransaction /// public async Task 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); }); } /// /// Void tax transaction /// /// Order /// A task that represents the asynchronous operation 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); }); } /// /// Delete tax transaction /// /// Order /// A task that represents the asynchronous operation 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); }); } /// /// Refund tax transaction /// /// Order /// Amount to refund /// A task that represents the asynchronous operation 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); }); } /// /// Download a file listing tax rates by postal code /// /// A task that represents the asynchronous operation 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 /// /// Checks whether the company is configured to use exemption certificates /// /// Whether to request the certificate setup /// /// A task that represents the asynchronous operation /// The task result contains the result of check /// public async Task GetCertificateSetupStatusAsync(bool request = false) { return await HandleFunctionAsync(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; }); } /// /// Create a new authorization token to launch the certificates services /// /// Customer for which token is creating /// /// A task that represents the asynchronous operation /// The task result contains the generated token /// public async Task 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"); }); } /// /// Get the certificate exposure zones defined by the company /// /// /// A task that represents the asynchronous operation /// The task result contains the list of exposure zones /// public async Task> 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; }); } /// /// Create or update the passed customer for the company /// /// Customer /// /// A task that represents the asynchronous operation /// The task result contains the customer details /// public async Task 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"); }); } /// /// Delete a customer with the passed identifier /// /// Customer id /// /// A task that represents the asynchronous operation /// The task result contains the customer details /// public async Task 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"); }); } /// /// Get valid certificates linked to a customer in a particular country and region /// /// Customer /// Current store id /// /// A task that represents the asynchronous operation /// The task result contains the list of certificates /// public async Task 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; }); } /// /// Get all certificates linked to a customer /// /// Customer /// /// A task that represents the asynchronous operation /// The task result contains the list of certificates /// public async Task> 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; }); } /// /// Download a PDF file for the certificate /// /// The unique ID number of the certificate /// /// A task that represents the asynchronous operation /// The task result contains the file details /// public async Task 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); }); } /// /// Get an existing invitation to upload certificates on the external website /// /// Customer /// /// A task that represents the asynchronous operation /// The task result contains the URL to redirect customer /// public async Task 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 { 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; }); } #endregion #region Item classification /// /// Get item classification /// /// /// A task that represents the asynchronous operation /// The task result contains the classification results /// public async Task<(HSClassificationModel model, string Error)> ClassificationProductsAsync(ItemClassification item) { return await HandleFunctionExAsync(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(new CreateHSClassificationRequest(companyId) { Id = classificationModel.Id, CountryOfDestination = classificationModel.CountryOfDestination, Item = classificationModel.Item }); return classification; }); } /// /// Handle webhook request /// /// HTTP request /// /// A task that represents the asynchronous operation 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(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 /// /// Dispose object /// 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 /// /// Gets client that connects to Avalara services /// 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 }