Resolving the entire Order Group from a Shipment in Episerver Commerce

Tax calculations can get messy and complex, but luckily Episerver makes things easy (at least from a developer perspective) to switch out the default tax calculation functionality.

Episerver provides an ITaxCalculator interface (EPiServer.Commerce.Order), which is used in tax calculations and as of Episerver Commerce 12 the GetTaxTotals method has been split off into their respective calculator classes: IOrderGroupCalculator, IOrderFormCalculator, IShippingCalculator and ILineItemCalculator (as well as some classes specifically for returns).

Interestingly this means when a tax total is being requested for a shipment it doesn't have the context of the order form or order group. This can have ramifications if you're integrating into a third-party tax solution to calculate your sales taxes -- because likely it will want to know the full context (for example, what about order form discounts? Those may need to be spread across shipments or even line items to get the most accurate calculation.)

Let's assume we want to create a custom ShippingCalculator, our GetSalesTax method needs to look something like:

public Money GetSalesTax(IShipment shipment, IMarket market, Currency currency)
{
    // 1. Get the order group for our shipment
    var orderGroup = GetOrderGroup(shipment);
    // 2. Call our third-party tax calcultion service
    // 3. Split out and return the shipment specific tax

    return new Money(decimal.Zero, Currency.USD);
}

Here we're simply addressing point 1, i.e. how should that GetOrderGroup method look? We know it's easy to get the order group where for an IShipment if we're not using serializable carts (but that's pretty unlikely now isn't it?). Anyway, our method will probably look something like:

internal virtual IOrderGroup GetOrderGroup(IShipment shipment)
{
    // Not using the serializable cart
    var persistedShipment = shipment as Shipment;

    if (persistedShipment != null)
    {
        // Get the parent order form for shipment stored in the db
        return persistedShipment.Parent?.Parent;
    }

    // Using the serializable cart
    if (shipment is SerializableShipment)
    {
        var cartId = shipment.Properties["CartId"] as int?;

        // Try and get the ID of the current cart
        if (!cartId.HasValue)
        {
            return null;
        }

        // Resolve and return the cart for the current ID
        return _lazyOrderRepository.Value.Load<ICart>(cartId.Value);
    }

    // We're dealing with a purchase order (placed order)
    var options = new OrderSearchOptions
    {
        RecordsToRetrieve = 1,
        CacheResults = true
    };

    options.Classes.Add("PurchaseOrder");
    var parameters = new OrderSearchParameters
    {
        SqlWhereClause =
            $"OrderGroup.OrderGroupId = (Select OrderGroupId from Shipment where ShipmentId = {shipment.ShipmentId})"
    };

    return OrderContext.Current?.FindPurchaseOrders(parameters, options).FirstOrDefault();
}

I think this is pretty self-explantory -- if the shipment isn't serializable then we can simply retrieve its parent's parent. Similarly, if the order is now a PurchaseOrder we can get it from the database via FindPurchaseOrders. The complexity is the middle section, if it is serializable then we get the CardId from properties and use this to load the correct cart.

The question here is: how do we set this CartId value on all shipments?

We can make sure it's set when a shipment is created by doing a implementation of IOrderGroupFactory with a custom CreateShipment method:

public IShipment CreateShipment(IOrderGroup orderGroup)
{
    // Create the shipment using the default order group factory
    var shipment = _orderGroupFactory.CreateShipment(orderGroup);
    // Whenever a shipment is created set the cart ID
    shipment.Properties["CartId"] = orderGroup.OrderLink.OrderGroupId;
    return shipment;
}

This works well but doesn't address changes to the cart ID when things are updated (for example, logging in when carts are merged). For this we need the last piece of the puzzle, an IOrderRepositoryCallback implementation:

internal class CustomOrderRepositoryCallback : IOrderRepositoryCallback
{
    private readonly Lazy<IOrderRepository> _lazyOrderRepository;

    public VertexOrderRepositoryCallback(Lazy<IOrderRepository> lazyOrderRepository)
    {
        _lazyOrderRepository = lazyOrderRepository;
    }

    public void OnUpdated(OrderReference orderReference)
    {
        if (orderReference.OrderType != typeof(SerializableCart))
        {
            return;
        }
        bool updated = false;

        foreach (var form in cart.Forms)
        {
            if (form.Shipments == null)
            {
                continue;
            }

            foreach (var shipment in form.Shipments)
            {
                cardId = shipment.Properties["CartId"] as int?;

                // If the cart ID is not set for the shipment, or is incorrect, set it now
                if (!cardId.HasValue || !cardId.Value.Equals(orderReference.OrderGroupId))
                {
                    shipment.Properties["CardId"] = orderReference.OrderGroupId;
                    updated = true;
                }
            }
        }

        // If we set a cart ID, we need to save the cart
        if (updated)
        {
            _lazyOrderRepository.Value.Save(cart);
        }
    }
}

What we're doing here is iterating over the shipments and verifying that the CartId property is still correct and set each time the cart is updated. If it's not, then we set the CardId property to the new OrderGroupId. We only save if we need to, avoiding a trip to the database...if possible!

What we've achieved here is a way of always resolving the context of our shipment (it's order group) -- which in this scenario means we can perform an accurate tax calculation! Of course, there is nothing here that strictly limits the application to this usage, but this was the situation I first encountered the issue. I'm definitely interested in any feedback on an easier approach, at the time of writing I'm not sure there is one...