Quan Mai
Nov 14, 2018
  4294
(3 votes)

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.

Nov 14, 2018

Comments

Jafet Valdez
Jafet Valdez Nov 14, 2018 07:28 PM

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.

Siddharth Gupta
Siddharth Gupta Nov 14, 2018 07:51 PM

Thanks @Quan Mai and  @Jafet Valdez.

Quan Mai
Quan Mai Nov 14, 2018 07:53 PM

@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)

Jafet Valdez
Jafet Valdez Nov 14, 2018 08:27 PM

@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;
    }
}

Quan Mai
Quan Mai Nov 14, 2018 08:27 PM

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

Jafet Valdez
Jafet Valdez Nov 14, 2018 09:41 PM

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.

valdis
valdis Nov 16, 2018 01:23 PM

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".

Jeroen van Lieshout
Jeroen van Lieshout Nov 19, 2018 07:51 AM

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?

Jacob Rastad
Jacob Rastad Feb 1, 2019 11:36 AM

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.
Latest blogs
Custom form element view in Optimizely CMS 12

Do you want full control over the form element markup? Create your own views!

Tomas Hensrud Gulla | Dec 11, 2024 | Syndicated blog

How to Elevate Your Experimentation - Opticon workshop experience

As a non-expert in the field of experimentation, I’d like to share my feedback on the recent Opticon San Antonio workshop session titled "How to...

David Ortiz | Dec 11, 2024

Persisting a Strawberry Shake GraphQL Client for Optimizely's Content Graph

A recent CMS project used Strawberry Shake to generate an up-to-date C# GraphQL client at each build. But what happens to the build if the GraphQL...

Nicholas Sideras | Dec 11, 2024 | Syndicated blog

Opti ID with Secure Cookies And Third Party AddOns

Opti ID has revolutionised access to the Optimizely One suite and is now the preferred authentication method on all PAAS CMS websites that I build....

Mark Stott | Dec 9, 2024