Commerce: Marking all prices/currencies required

Hello :waving_hand:
Another day, another Commerce question.

Problem

How do I mark the price for all currencies as required?

Why?

I want to ensure all products have an explicit price set in GBP, EUR and USD. Currently when marking the property with the Umbraco Commerce Price datatype will only mark it as required if all three currencies do not have any value set.

UX Problem

  • A user adds a product to their cart that has a value set in GBP
  • Editor forgot to set a price in USD & the input field is empty/blank
  • User uses currency switcher and changes to USD
  • Items in the basket use the USD prices
  • But for the product without an explicit price set - it is now marked as 0.00 aka FREE

The client has informed us that all products are available to be bought in all currencies, so my thought was to mark the price editor as mandatory and it would flag if any of the currency input boxes was left without an explicit value set.

Suggestions or ideas

Do you have any ideas or suggestions on how I could solve this?

Thanks,
Warren :slight_smile:

Hi @warren

Price is not stored against the content itself, so the mandatory check is just checking that something has been populated I guess - not sure how that’s been done under the covers in the property value converter/validator. You may be able to see how Umbraco Commerce does this using DotPeek and see if you can replace the property validator or alternatively use a ContentPublishingNotification handler to add your own validation prior to publish? Just a suggestion rather than a definite answer!

Justin

Thanks @justin-nevitech yeh off to take a nose, it might have to be dealt with a ContentPublishingNotification but was hoping to see if there was anything out of the box or anything misconfigured from me.

May be worth adding to the Umbraco Commerce Issues to see what Umbraco’s view is on this, as I agree that if it’s mandatory it should be mandatory for all currencies?

Current Solution

I am not in love with this, but this is the approach/solution using a ContentPublishingNotification

ValidateProductPricesHandler.cs

using System.Text.Json;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Commerce.Core.Models;
using Umbraco.Commerce.Core.Services;

namespace MySite.Core.Notifications;

/// <summary>
/// Blocks publishing of product content unless every store currency has an explicit price set.
/// This catches the case where a price is left blank for one currency, which Umbraco Commerce
/// treats as 0.00 (free) rather than blocking publish — causing items to appear free for
/// customers using that currency.
///
/// Note: 0.00 is intentionally allowed as a valid price for genuinely free products.
/// Only null/blank values (where the editor never touched the field) are rejected.
/// </summary>
public class ValidateProductPricesHandler : INotificationAsyncHandler<ContentPublishingNotification>
{
    private static readonly HashSet<string> ProductAliases = new(StringComparer.OrdinalIgnoreCase)
    {
        "document", "webinar", "trainingCourse", "digitalProduct", "productVariant",
    };

    private const string PriceAlias = "price";

    private readonly ICurrencyService _currencyService;
    private readonly IDataTypeService _dataTypeService;

    public ValidateProductPricesHandler(ICurrencyService currencyService, IDataTypeService dataTypeService)
    {
        _currencyService = currencyService;
        _dataTypeService = dataTypeService;
    }

    public async Task HandleAsync(ContentPublishingNotification notification, CancellationToken cancellationToken)
    {
        foreach (IContent content in notification.PublishedEntities)
        {
            if (!ProductAliases.Contains(content.ContentType.Alias) || !content.HasProperty(PriceAlias))
            {
                continue;
            }

            Guid? storeId = await GetStoreIdFromPricePropertyAsync(content);
            if (storeId is null)
            {
                continue;
            }

            IEnumerable<CurrencyReadOnly> currenciesResult = await _currencyService.GetCurrenciesAsync(storeId.Value);
            var currencies = currenciesResult.ToList();
            if (currencies.Count == 0)
            {
                continue;
            }

            string? rawJson = content.GetValue<string>(PriceAlias);
            List<string> unset = GetUnsetCurrencies(rawJson, currencies);

            if (unset.Count == 0)
            {
                continue;
            }

            notification.Cancel = true;
            notification.Messages.Add(new EventMessage(
                "Validation",
                $"'{content.Name}' cannot be published. A price must be explicitly set for: {string.Join(", ", unset)}. (0.00 is valid for free products — leave no field blank.)",
                EventMessageType.Error));
        }
    }

    /// <summary>
    /// Reads the storeId from the Umbraco Commerce price datatype's configuration.
    /// The price property editor stores the associated store ID in its datatype config,
    /// so we can look it up without hardcoding.
    /// </summary>
    private async Task<Guid?> GetStoreIdFromPricePropertyAsync(IContent content)
    {
        IProperty? property = content.Properties.FirstOrDefault(p => p.Alias == PriceAlias);
        if (property is null)
        {
            return null;
        }

        IDataType? dataType = await _dataTypeService.GetAsync(property.PropertyType.DataTypeKey);
        if (dataType?.ConfigurationData.TryGetValue("storeId", out object? storeIdObj) == true
            && Guid.TryParse(storeIdObj?.ToString(), out Guid storeId))
        {
            return storeId;
        }

        return null;
    }

    /// <summary>
    /// Umbraco Commerce stores price values as JSON: { "currencyGuid": decimal | null }.
    /// A blank input box is stored as null (not 0), so we reject null/missing entries only.
    /// An explicit 0.00 is a valid price for a free product and is allowed through.
    /// </summary>
    private static List<string> GetUnsetCurrencies(string? rawJson, IReadOnlyList<CurrencyReadOnly> currencies)
    {
        if (string.IsNullOrWhiteSpace(rawJson))
        {
            return currencies.Select(c => c.Code).ToList();
        }

        Dictionary<Guid, decimal?> prices;
        try
        {
            prices = JsonSerializer.Deserialize<Dictionary<Guid, decimal?>>(rawJson)
                     ?? new Dictionary<Guid, decimal?>();
        }
        catch (JsonException)
        {
            return currencies.Select(c => c.Code).ToList();
        }

        return currencies
            .Where(c => !prices.TryGetValue(c.Id, out decimal? v) || !v.HasValue)
            .Select(c => c.Code)
            .ToList();
    }
}

program.cs

// Added to Umbraco Builder
.AddNotificationAsyncHandler<ContentPublishingNotification, ValidateProductPricesHandler>()

A means to an end… It may still be worth raising with HQ as well to get their opinion?

Asked on the Umbraco MVP channel and will see if a HQ swings by here and gives any thoughts on this. If dont hear then will go and post an issue and see what happens.

2 Likes