Views: 1153
Number of votes: 3
Average rating:

Control the rounding level of currencies

Promotion engine rounds the discount value to the known decimal digits of the order currency. The official decimal digits of currencies are defined by ISO 4217 standard, however as a business, you might have different requirements. For example, normally, you will only see $12.34 , but it's not very uncommon for you to have a price of $12.3456. This might cause some problem where something that should be free, is not, or even worse, the actual cost of something (like shipping cost) will be negative, causing validation error.

That is, however can be worked around by controlling the number of decimal digits for a currency. In the example above, it might make senses to set CurrencyDecimalDigits of USD to 4. This can be done by this snippet code added to an initialization module, which has side-wide affect:

var f = Currency.USD.Format;
f.CurrencyDecimalDigits = 4;
Currency.SetFormat(Currency.USD.CurrencyCode, f);

This is, however, not without drawback. Any amount in USD will have 4 decimal digits, so they might not be displayed nicely to end user. I would suspect most end users would like to see USD 12.34, instead of USD 12.3456 in their cart page. That is something you might to think about.

We have some changes that will fix the rounding issues completely (I hope!), but that requires breaking changes so it will have to wait until Commerce 13.

For now, the workaround above should work.

Cool to hear that a fix is on the way! :D

When looking at this issue a few days back I came up with another temporary solution that is a bit more verbose, but doesn't affect the presentation of the entire site.

It involves overriding the Run method on the Promotion Engine and looks like this:

    public class MyPromotionEngine : EPiServer.Commerce.Marketing.PromotionEngine
    {
        // Ommitted constructors

        private readonly Dictionary<Currency, int> _roundingOverrides = new Dictionary<Currency, int>()
        {
            { Currency.USD, 4 }
        };

        public override IEnumerable<RewardDescription> Run(IOrderGroup orderGroup, PromotionEngineSettings settings)
        {
            if (_roundingOverrides.ContainsKey(orderGroup.Currency))
                return RunWithRoundingOverride(orderGroup, settings, _roundingOverrides[orderGroup.Currency]);

            return base.Run(orderGroup, settings);
        }

        private IEnumerable<RewardDescription> RunWithRoundingOverride(IOrderGroup orderGroup, PromotionEngineSettings settings, int roundingOverride)
        {
            var oldDecimalDigit = orderGroup.Currency.Format.CurrencyDecimalDigits;
            SetRoundingDigits(orderGroup, roundingOverride);
            var result = base.Run(orderGroup, settings);
            SetRoundingDigits(orderGroup, oldDecimalDigit);

            return result;
        }

        private void SetRoundingDigits(IOrderGroup orderGroup, int digits)
        {
            orderGroup.Currency.Format.CurrencyDecimalDigits = digits;
        }
    }

But I haven't tried it in a real project, so I'm not sure if it could cause an issue further down the line if some values get rounded in one way but discounts get rounded in another. But I assume so.

Thanks @Quan Mai and  @Jafet Valdez.

@Jafet Valdez: each approach has its pros and cons, but I would suggest to go with interceptor pattern instead of inheriting directly from PromotionEngine (I don't even know why we made that public)

@Quan, yeah. That makes a lot more sense. Thanks!

At the risk of spamming the comments with code, this would be the new version:

SiteInitialization (IConfigurableModule.ConfigureContainer)

context.Services.Intercept<IPromotionEngine>((locator, defaultImplementation) =>
new MyPromotionEngine(defaultImplementation));

MyPromotionEngine

public class MyPromotionEngine : IPromotionEngine
{
    private readonly IPromotionEngine _defaultImplementation;

    public MyPromotionEngine(IPromotionEngine defaultImplementation)
    {
        _defaultImplementation = defaultImplementation;
    }

    private readonly Dictionary<Currency, int> _roundingOverrides = new Dictionary<Currency, int>()
    {
        { Currency.USD, 4 }
    };

    public IEnumerable<RewardDescription> Run(IOrderGroup orderGroup, PromotionEngineSettings settings)
    {
        if (_roundingOverrides.ContainsKey(orderGroup.Currency))
            return RunWithRoundingOverride(orderGroup, settings, _roundingOverrides[orderGroup.Currency]);

        return _defaultImplementation.Run(orderGroup, settings);
    }

    public IEnumerable<RewardDescription> Evaluate(IEnumerable<ContentReference> entryLinks, IMarket market, Currency currency, RequestFulfillmentStatus requestFulfillmentStatus)
    {
        return _defaultImplementation.Evaluate(entryLinks, market, currency, requestFulfillmentStatus);
    }

    private IEnumerable<RewardDescription> RunWithRoundingOverride(IOrderGroup orderGroup, PromotionEngineSettings settings, int roundingOverride)
    {
        var oldDecimalDigit = orderGroup.Currency.Format.CurrencyDecimalDigits;
        SetRoundingDigits(orderGroup, roundingOverride);
        var result = _defaultImplementation.Run(orderGroup, settings);
        SetRoundingDigits(orderGroup, oldDecimalDigit);

        return result;
    }

    private void SetRoundingDigits(IOrderGroup orderGroup, int digits)
    {
        orderGroup.Currency.Format.CurrencyDecimalDigits = digits;
    }
}

Keep bringing your sample code. That' good work, not spamming :)

Cool to hear that a fix is on the way! :D

When looking at this issue a few days back I came up with another temporary solution that is a bit more verbose, but doesn't affect the presentation of the entire site.

It involves overriding the Run method on the Promotion Engine and looks like this:

    public class MyPromotionEngine : EPiServer.Commerce.Marketing.PromotionEngine
    {
        // Ommitted constructors

        private readonly Dictionary<Currency, int> _roundingOverrides = new Dictionary<Currency, int>()
        {
            { Currency.USD, 4 }
        };

        public override IEnumerable<RewardDescription> Run(IOrderGroup orderGroup, PromotionEngineSettings settings)
        {
            if (_roundingOverrides.ContainsKey(orderGroup.Currency))
                return RunWithRoundingOverride(orderGroup, settings, _roundingOverrides[orderGroup.Currency]);

            return base.Run(orderGroup, settings);
        }

        private IEnumerable<RewardDescription> RunWithRoundingOverride(IOrderGroup orderGroup, PromotionEngineSettings settings, int roundingOverride)
        {
            var oldDecimalDigit = orderGroup.Currency.Format.CurrencyDecimalDigits;
            SetRoundingDigits(orderGroup, roundingOverride);
            var result = base.Run(orderGroup, settings);
            SetRoundingDigits(orderGroup, oldDecimalDigit);

            return result;
        }

        private void SetRoundingDigits(IOrderGroup orderGroup, int digits)
        {
            orderGroup.Currency.Format.CurrencyDecimalDigits = digits;
        }
    }

But I haven't tried it in a real project, so I'm not sure if it could cause an issue further down the line if some values get rounded in one way but discounts get rounded in another. But I assume so.

I would not call implementation you receive in interceptor as "default" :) as there might be other friends of yours to do the same thing. I usually refer to them as "inner".

Thanks for this workaround! I see still one place after implementing this workaround where it is not working properly. On OrderForm level there is a promotion list. The SavedAmount properties per promotion are not stored with respecting the decimals.

For example I have logic, where I determine per promotion type the saved amount, as we have an external order system which needs to know that information to process promotions properly. Therefore we process per lineitem in the order the promotions like this, such that we later can do some calculations with it:

// Get promotions for this lineitem
var promotions = ((IOrderForm)lineItem.Parent).Promotions.Where(x =>
               x.DiscountType != EPiServer.Commerce.Marketing.DiscountType.Order && x.Entries != null &&
               x.Entries.Any(y => y.EntryCode == lineItem.Code));
            
            // Preprocess adjustments to determine amount per type
            foreach (var promotion in promotions)
            {
                var adjustment = new Adjustment(promotion, currency, lineItem.Code);
                // In case of free gift, the discount amount in promotion is 0, so take lineitem discount. Otherwise calculate
                var currentAmount = (adjustment.AdjustmentType == PromotionCodes.FreeProduct)
                    ? lineItem.LineItemDiscountAmount
                    : promotion.Entries.Find(x => x.EntryCode == lineItem.Code).SavedAmount;
                adjustment.AdjustmentAmount = currentAmount;

                if (!promotionTypes.Contains(adjustment.AdjustmentType))
                    promotionTypes.Add(adjustment.AdjustmentType);

                adjustments.Add(adjustment);
            }

What we currently see is that SavedAmount is only using max 3 decimal digits (still some rounding is taking place there). On LineItem level in the Order, the SavedAmount of all promotions is indeed correctly saved, but not on individual promotion level. Is there a way how to retrieve this info with the decimal places respected according to the workaround?

We made a slightly different approach which caused side effects on production during load.
Since we have a lot of currencies we decided to increase the currency decimal digits by 1 during promition calculation.
 
This caused a lot of our prices to show up with up to 10-15 decimals on the site. It seems that the currency format can be used to display prices at the same time that promotion engine runs. Have any other of you experienced this issue?
It appears that the setting is not thread safe.
 
We were able to reproduce it on staging by simulating load to multiple uncached pages at the same time.
 

Our code:

private IEnumerable<RewardDescription> RunWithRoundingOverride(IOrderGroup orderGroup, PromotionEngineSettings settings)
        {
            var oldDecimalDigit = orderGroup.Currency.Format.CurrencyDecimalDigits;
            SetRoundingDigits(orderGroup, oldDecimalDigit + 1);
            var result = _defaultImplementation.Run(orderGroup, settings);
            SetRoundingDigits(orderGroup, oldDecimalDigit);

            return result;
        }

Since there is a bug in the promotion engine causing campaigns to be rounded with to few decimals and then in turn causing issues with payments (e.g. we send a total amount with 1 cent to much or to little). This in turn causes the order to be stopped in the warehouse since the payment doesn't match the amount in the ERP system (that doesn't have any rounding error).

We are in a dire need of a workaround until the promotion engine is fixed in v13.

Please login to comment.