Try our conversational search powered by Generative AI!

Quan Mai
Nov 14, 2018
  4029
(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
Optimizely and the never-ending story of the missing globe!

I've worked with Optimizely CMS for 14 years, and there are two things I'm obsessed with: Link validation and the globe that keeps disappearing on...

Tomas Hensrud Gulla | Apr 18, 2024 | Syndicated blog

Visitor Groups Usage Report For Optimizely CMS 12

This add-on offers detailed information on how visitor groups are used and how effective they are within Optimizely CMS. Editors can monitor and...

Adnan Zameer | Apr 18, 2024 | Syndicated blog

Azure AI Language – Abstractive Summarisation in Optimizely CMS

In this article, I show how the abstraction summarisation feature provided by the Azure AI Language platform, can be used within Optimizely CMS to...

Anil Patel | Apr 18, 2024 | Syndicated blog

Fix your Search & Navigation (Find) indexing job, please

Once upon a time, a colleague asked me to look into a customer database with weird spikes in database log usage. (You might start to wonder why I a...

Quan Mai | Apr 17, 2024 | Syndicated blog

The A/A Test: What You Need to Know

Sure, we all know what an A/B test can do. But what is an A/A test? How is it different? With an A/B test, we know that we can take a webpage (our...

Lindsey Rogers | Apr 15, 2024

.Net Core Timezone ID's Windows vs Linux

Hey all, First post here and I would like to talk about Timezone ID's and How Windows and Linux systems use different IDs. We currently run a .NET...

sheider | Apr 15, 2024