diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index b5f06ee..d260780 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -24,7 +24,7 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: 9 - + - name: Build run: dotnet build Engine/Quokka.slnx --configuration Release diff --git a/Engine/Directory.Build.props b/Engine/Directory.Build.props index b0e18f5..ac66d0e 100644 --- a/Engine/Directory.Build.props +++ b/Engine/Directory.Build.props @@ -16,6 +16,6 @@ - 8.3.0 + 8.4.0 diff --git a/Engine/Quokka.Core/Functions/Standard/Money/CurrencyAmountFormats.cs b/Engine/Quokka.Core/Functions/Standard/Money/CurrencyAmountFormats.cs new file mode 100644 index 0000000..bcc200e --- /dev/null +++ b/Engine/Quokka.Core/Functions/Standard/Money/CurrencyAmountFormats.cs @@ -0,0 +1,35 @@ +// // Copyright 2022 Mindbox Ltd +// // +// // Licensed under the Apache License, Version 2.0 (the "License"); +// // you may not use this file except in compliance with the License. +// // You may obtain a copy of the License at +// // +// // http://www.apache.org/licenses/LICENSE-2.0 +// // +// // Unless required by applicable law or agreed to in writing, software +// // distributed under the License is distributed on an "AS IS" BASIS, +// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// // See the License for the specific language governing permissions and +// // limitations under the License. + +using System.Globalization; + +namespace Mindbox.Quokka +{ + internal static class CurrencyAmountFormats + { + public const int DefaultDecimalPlaces = 2; + + private static readonly string[] formatsByDecimalPlaces = ["N0", "N1", "N2", "N3", "N4"]; + + public static readonly NumberFormatInfo NumberFormat = NumberFormatInfo.ReadOnly( + new NumberFormatInfo + { + NumberGroupSeparator = ",", + NumberDecimalSeparator = ".", + NumberGroupSizes = [3] + }); + + public static string ForDecimalPlaces(int decimalPlaces) => formatsByDecimalPlaces[decimalPlaces]; + } +} diff --git a/Engine/Quokka.Core/Functions/Standard/Money/CurrencyDisplayMode.cs b/Engine/Quokka.Core/Functions/Standard/Money/CurrencyDisplayMode.cs new file mode 100644 index 0000000..f8ae804 --- /dev/null +++ b/Engine/Quokka.Core/Functions/Standard/Money/CurrencyDisplayMode.cs @@ -0,0 +1,63 @@ +// // Copyright 2022 Mindbox Ltd +// // +// // Licensed under the Apache License, Version 2.0 (the "License"); +// // you may not use this file except in compliance with the License. +// // You may obtain a copy of the License at +// // +// // http://www.apache.org/licenses/LICENSE-2.0 +// // +// // Unless required by applicable law or agreed to in writing, software +// // distributed under the License is distributed on an "AS IS" BASIS, +// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// // See the License for the specific language governing permissions and +// // limitations under the License. + +using System; + +namespace Mindbox.Quokka +{ + internal enum CurrencyDisplayMode + { + NarrowSymbol, + Symbol, + Code + } + + internal static class CurrencyDisplayModes + { + public const string NarrowSymbolName = "narrowSymbol"; + public const string SymbolName = "symbol"; + public const string CodeName = "code"; + + public static bool TryParse(string value, out CurrencyDisplayMode mode) + { + var name = value?.Trim(); + if (string.IsNullOrEmpty(name) || Matches(name, NarrowSymbolName)) + { + mode = CurrencyDisplayMode.NarrowSymbol; + return true; + } + + if (Matches(name, SymbolName)) + { + mode = CurrencyDisplayMode.Symbol; + return true; + } + + if (Matches(name, CodeName)) + { + mode = CurrencyDisplayMode.Code; + return true; + } + + mode = CurrencyDisplayMode.NarrowSymbol; + return false; + } + + public static CurrencyDisplayMode ParseOrDefault(string value) => + TryParse(value, out var mode) ? mode : CurrencyDisplayMode.NarrowSymbol; + + private static bool Matches(string value, string name) => + string.Equals(value, name, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/Engine/Quokka.Core/Functions/Standard/Money/CurrencyFormat.cs b/Engine/Quokka.Core/Functions/Standard/Money/CurrencyFormat.cs new file mode 100644 index 0000000..368b09c --- /dev/null +++ b/Engine/Quokka.Core/Functions/Standard/Money/CurrencyFormat.cs @@ -0,0 +1,38 @@ +// // Copyright 2022 Mindbox Ltd +// // +// // Licensed under the Apache License, Version 2.0 (the "License"); +// // you may not use this file except in compliance with the License. +// // You may obtain a copy of the License at +// // +// // http://www.apache.org/licenses/LICENSE-2.0 +// // +// // Unless required by applicable law or agreed to in writing, software +// // distributed under the License is distributed on an "AS IS" BASIS, +// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// // See the License for the specific language governing permissions and +// // limitations under the License. + +namespace Mindbox.Quokka +{ + internal sealed class CurrencyFormat + { + public CurrencyFormat(string narrowSymbol, string symbol, int decimalPlaces, bool spaceAfterSymbol = false) + { + NarrowSymbol = narrowSymbol; + Symbol = symbol; + DecimalPlaces = decimalPlaces; + SpaceAfterSymbol = spaceAfterSymbol; + AmountFormat = CurrencyAmountFormats.ForDecimalPlaces(decimalPlaces); + } + + public string NarrowSymbol { get; } + + public string Symbol { get; } + + public int DecimalPlaces { get; } + + public bool SpaceAfterSymbol { get; } + + public string AmountFormat { get; } + } +} diff --git a/Engine/Quokka.Core/Functions/Standard/Money/CurrencyFormats.cs b/Engine/Quokka.Core/Functions/Standard/Money/CurrencyFormats.cs new file mode 100644 index 0000000..ef5fed7 --- /dev/null +++ b/Engine/Quokka.Core/Functions/Standard/Money/CurrencyFormats.cs @@ -0,0 +1,42 @@ +// // Copyright 2022 Mindbox Ltd +// // +// // Licensed under the Apache License, Version 2.0 (the "License"); +// // you may not use this file except in compliance with the License. +// // You may obtain a copy of the License at +// // +// // http://www.apache.org/licenses/LICENSE-2.0 +// // +// // Unless required by applicable law or agreed to in writing, software +// // distributed under the License is distributed on an "AS IS" BASIS, +// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// // See the License for the specific language governing permissions and +// // limitations under the License. + +using System; +using System.Collections.Concurrent; +using NodaMoney; + +namespace Mindbox.Quokka +{ + internal static class CurrencyFormats + { + private static readonly ConcurrentDictionary knownFormats = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + public static CurrencyFormat TryGet(string currencyCode) + { + if (string.IsNullOrEmpty(currencyCode)) + return null; + + if (knownFormats.TryGetValue(currencyCode, out var knownFormat)) + return knownFormat; + + if (!CurrencyInfo.TryFromCode(currencyCode, out var currency)) + return null; + + var format = new CurrencyFormat(currency.Symbol, currency.InternationalSymbol, currency.DecimalDigits); + knownFormats[currencyCode] = format; + return format; + } + } +} diff --git a/Engine/Quokka.Core/Functions/Standard/Money/FormatMoneyTemplateFunction.cs b/Engine/Quokka.Core/Functions/Standard/Money/FormatMoneyTemplateFunction.cs new file mode 100644 index 0000000..aab60a5 --- /dev/null +++ b/Engine/Quokka.Core/Functions/Standard/Money/FormatMoneyTemplateFunction.cs @@ -0,0 +1,34 @@ +// // Copyright 2022 Mindbox Ltd +// // +// // Licensed under the Apache License, Version 2.0 (the "License"); +// // you may not use this file except in compliance with the License. +// // You may obtain a copy of the License at +// // +// // http://www.apache.org/licenses/LICENSE-2.0 +// // +// // Unless required by applicable law or agreed to in writing, software +// // distributed under the License is distributed on an "AS IS" BASIS, +// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// // See the License for the specific language governing permissions and +// // limitations under the License. + +using Mindbox.Quokka.Abstractions; + +namespace Mindbox.Quokka +{ + internal class FormatMoneyTemplateFunction : ScalarTemplateFunction + { + public FormatMoneyTemplateFunction() + : base( + "formatMoney", + new DecimalFunctionArgument("amount", allowsNull: true), + new StringFunctionArgument("currencyCode", allowsNull: true)) + { + } + + public override string Invoke(RenderSettings settings, decimal amount, string currencyCode) + { + return MoneyFormatter.Format(amount, currencyCode, CurrencyDisplayMode.NarrowSymbol); + } + } +} diff --git a/Engine/Quokka.Core/Functions/Standard/Money/FormatMoneyWithDisplayModeTemplateFunction.cs b/Engine/Quokka.Core/Functions/Standard/Money/FormatMoneyWithDisplayModeTemplateFunction.cs new file mode 100644 index 0000000..83023a5 --- /dev/null +++ b/Engine/Quokka.Core/Functions/Standard/Money/FormatMoneyWithDisplayModeTemplateFunction.cs @@ -0,0 +1,45 @@ +// // Copyright 2022 Mindbox Ltd +// // +// // Licensed under the Apache License, Version 2.0 (the "License"); +// // you may not use this file except in compliance with the License. +// // You may obtain a copy of the License at +// // +// // http://www.apache.org/licenses/LICENSE-2.0 +// // +// // Unless required by applicable law or agreed to in writing, software +// // distributed under the License is distributed on an "AS IS" BASIS, +// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// // See the License for the specific language governing permissions and +// // limitations under the License. + +using Mindbox.Quokka.Abstractions; + +namespace Mindbox.Quokka +{ + internal class FormatMoneyWithDisplayModeTemplateFunction : ScalarTemplateFunction + { + public FormatMoneyWithDisplayModeTemplateFunction() + : base( + "formatMoney", + new DecimalFunctionArgument("amount", allowsNull: true), + new StringFunctionArgument("currencyCode", allowsNull: true), + new StringFunctionArgument("displayMode", allowsNull: true, valueValidator: ValidateDisplayMode)) + { + } + + public override string Invoke(RenderSettings settings, decimal amount, string currencyCode, string displayMode) + { + return MoneyFormatter.Format(amount, currencyCode, CurrencyDisplayModes.ParseOrDefault(displayMode)); + } + + private static ArgumentValueValidationResult ValidateDisplayMode(string displayMode) + { + return CurrencyDisplayModes.TryParse(displayMode, out _) + ? ArgumentValueValidationResult.Valid + : new ArgumentValueValidationResult( + false, + $"Display mode should be one of: {CurrencyDisplayModes.NarrowSymbolName}, " + + $"{CurrencyDisplayModes.SymbolName}, {CurrencyDisplayModes.CodeName}"); + } + } +} diff --git a/Engine/Quokka.Core/Functions/Standard/Money/MoneyFormatter.cs b/Engine/Quokka.Core/Functions/Standard/Money/MoneyFormatter.cs new file mode 100644 index 0000000..17b37f3 --- /dev/null +++ b/Engine/Quokka.Core/Functions/Standard/Money/MoneyFormatter.cs @@ -0,0 +1,54 @@ +// // Copyright 2022 Mindbox Ltd +// // +// // Licensed under the Apache License, Version 2.0 (the "License"); +// // you may not use this file except in compliance with the License. +// // You may obtain a copy of the License at +// // +// // http://www.apache.org/licenses/LICENSE-2.0 +// // +// // Unless required by applicable law or agreed to in writing, software +// // distributed under the License is distributed on an "AS IS" BASIS, +// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// // See the License for the specific language governing permissions and +// // limitations under the License. + +using System; + +namespace Mindbox.Quokka +{ + internal static class MoneyFormatter + { + private const string MinusSign = "-"; + private const string Space = " "; + + public static string Format(decimal amount, string currencyCode, CurrencyDisplayMode displayMode) + { + var code = currencyCode?.Trim(); + var format = CurrencyFormats.TryGet(code); + var decimalPlaces = format?.DecimalPlaces ?? CurrencyAmountFormats.DefaultDecimalPlaces; + var absolute = Math.Round(Math.Abs(amount), decimalPlaces, MidpointRounding.AwayFromZero); + + var formattedAmount = absolute.ToString( + format?.AmountFormat ?? CurrencyAmountFormats.ForDecimalPlaces(decimalPlaces), + CurrencyAmountFormats.NumberFormat); + + var sign = amount < 0 && absolute != decimal.Zero ? MinusSign : string.Empty; + + if (format == null) + return string.IsNullOrEmpty(code) + ? sign + formattedAmount + : sign + formattedAmount + Space + code.ToUpperInvariant(); + + if (displayMode == CurrencyDisplayMode.Code) + return sign + code.ToUpperInvariant() + Space + formattedAmount; + + var symbol = displayMode == CurrencyDisplayMode.Symbol ? format.Symbol : format.NarrowSymbol; + var lastCharacter = symbol[symbol.Length - 1]; + var separator = format.SpaceAfterSymbol || char.IsLetter(lastCharacter) || lastCharacter == '.' + ? Space + : string.Empty; + + return sign + symbol + separator + formattedAmount; + } + } +} diff --git a/Engine/Quokka.Core/Mindbox.Quokka.csproj b/Engine/Quokka.Core/Mindbox.Quokka.csproj index f0fb783..485ffad 100644 --- a/Engine/Quokka.Core/Mindbox.Quokka.csproj +++ b/Engine/Quokka.Core/Mindbox.Quokka.csproj @@ -18,6 +18,7 @@ + diff --git a/Engine/Quokka.Core/Template.cs b/Engine/Quokka.Core/Template.cs index 04d3e05..fe117f9 100644 --- a/Engine/Quokka.Core/Template.cs +++ b/Engine/Quokka.Core/Template.cs @@ -217,6 +217,8 @@ internal static IEnumerable GetStandardFunctions() yield return new ToLowerTemplateFunction(); yield return new ReplaceIfEmptyTemplateFunction(); yield return new FormatDecimalTemplateFunction(); + yield return new FormatMoneyTemplateFunction(); + yield return new FormatMoneyWithDisplayModeTemplateFunction(); yield return new FormatDateTimeTemplateFunction(); yield return new FormatTimeTemplateFunction(); yield return new IfTemplateFunction(); diff --git a/Engine/Quokka.Tests/Functions/FormatMoneyTemplateFunctionTests.cs b/Engine/Quokka.Tests/Functions/FormatMoneyTemplateFunctionTests.cs new file mode 100644 index 0000000..2df6907 --- /dev/null +++ b/Engine/Quokka.Tests/Functions/FormatMoneyTemplateFunctionTests.cs @@ -0,0 +1,243 @@ +// // Copyright 2022 Mindbox Ltd +// // +// // Licensed under the Apache License, Version 2.0 (the "License"); +// // you may not use this file except in compliance with the License. +// // You may obtain a copy of the License at +// // +// // http://www.apache.org/licenses/LICENSE-2.0 +// // +// // Unless required by applicable law or agreed to in writing, software +// // distributed under the License is distributed on an "AS IS" BASIS, +// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// // See the License for the specific language governing permissions and +// // limitations under the License. + +using System.Globalization; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Mindbox.Quokka.Abstractions; + +namespace Mindbox.Quokka.Tests +{ + [TestClass] + public class FormatMoneyTemplateFunctionTests + { + [TestMethod] + [DataRow("USD", "$1,234,567.89")] + [DataRow("GBP", "£1,234,567.89")] + [DataRow("EUR", "€1,234,567.89")] + [DataRow("RUB", "₽1,234,567.89")] + [DataRow("INR", "₹1,234,567.89")] + [DataRow("CHF", "Fr. 1,234,567.89")] + [DataRow("KWD", "د.ك 1,234,567.890")] + [DataRow("NGN", "₦1,234,567.89")] + [DataRow("RON", "lei 1,234,567.89")] + [DataRow("PKR", "Rs 1,234,567.89")] + [DataRow("HUF", "Ft 1,234,567.89")] + [DataRow("IDR", "Rp 1,234,567.89")] + public void FormatMoney_PutsTheSymbolFirstAndGroupsInThousands(string currencyCode, string expected) + { + Assert.AreEqual(expected, RenderMoney("1234567.89", currencyCode)); + } + + [TestMethod] + [DataRow("JPY", "¥9,072")] + [DataRow("KRW", "₩9,072")] + [DataRow("VND", "₫9,072")] + public void FormatMoney_WithZeroDecimalCurrency_DropsDecimals(string currencyCode, string expected) + { + Assert.AreEqual(expected, RenderMoney("9072.00", currencyCode)); + } + + [TestMethod] + public void FormatMoney_WithThreeDecimalCurrency_KeepsThreeDecimals() + { + Assert.AreEqual("د.ك 1,234.560", RenderMoney("1234.56", "KWD")); + } + + [TestMethod] + [DataRow("USD", "-$1,234.56")] + [DataRow("RUB", "-₽1,234.56")] + [DataRow("KWD", "-د.ك 1,234.560")] + public void FormatMoney_WithNegativeAmount_PutsTheSignFirst(string currencyCode, string expected) + { + Assert.AreEqual(expected, RenderMoney("-1234.56", currencyCode)); + } + + [TestMethod] + [DataRow("USD", "US$1,234.56")] + [DataRow("CAD", "CA$1,234.56")] + [DataRow("AUD", "A$1,234.56")] + [DataRow("SGD", "S$1,234.56")] + [DataRow("HKD", "HK$1,234.56")] + [DataRow("NZD", "NZ$1,234.56")] + [DataRow("TWD", "NT$1,234.56")] + public void FormatMoney_WithSymbolDisplayMode_DisambiguatesSharedSymbols(string currencyCode, string expected) + { + Assert.AreEqual(expected, RenderMoney("1234.56", currencyCode, "symbol")); + } + + [TestMethod] + [DataRow("EUR", "€1,234.56")] + [DataRow("GBP", "£1,234.56")] + [DataRow("RUB", "₽1,234.56")] + [DataRow("THB", "฿1,234.56")] + public void FormatMoney_WithSymbolDisplayMode_KeepsUnambiguousSymbols(string currencyCode, string expected) + { + Assert.AreEqual(expected, RenderMoney("1234.56", currencyCode, "symbol")); + } + + [TestMethod] + [DataRow("USD", "USD 1,234.56")] + [DataRow("usd", "USD 1,234.56")] + [DataRow("xyz", "1,234.56 XYZ")] + public void FormatMoney_WithCodeDisplayMode_PrefixesUppercasedIsoCode(string currencyCode, string expected) + { + Assert.AreEqual(expected, RenderMoney("1234.56", currencyCode, "code")); + } + + [TestMethod] + [DataRow("jpy")] + [DataRow(" JPY ")] + public void FormatMoney_WithLowercaseOrPaddedCode_ResolvesTheSameFormat(string currencyCode) + { + Assert.AreEqual("¥9,072", RenderMoney("9072.00", currencyCode)); + } + + [TestMethod] + public void FormatMoney_WithUnknownCurrencyCode_FallsBackToAmountAndCode() + { + Assert.AreEqual("1,234.56 XYZ", RenderMoney("1234.56", "XYZ")); + } + + [TestMethod] + [DataRow("TND", "د.ت 1,234.560")] + [DataRow("UGX", "USh 1,235")] + public void FormatMoney_WithNonTwoDecimalCurrency_UsesItsMinorUnits(string currencyCode, string expected) + { + Assert.AreEqual(expected, RenderMoney("1234.56", currencyCode)); + } + + [TestMethod] + [DataRow("-0.004", "USD", "$0.00")] + [DataRow("-0.4", "JPY", "¥0")] + [DataRow("0", "USD", "$0.00")] + public void FormatMoney_WithAmountRoundingToZero_DoesNotRenderSignedZero( + string amount, + string currencyCode, + string expected) + { + Assert.AreEqual(expected, RenderMoney(amount, currencyCode)); + } + + [TestMethod] + [DataRow("SYMBOL", "US$1,234.56")] + [DataRow(" code ", "USD 1,234.56")] + [DataRow("NarrowSymbol", "$1,234.56")] + public void FormatMoney_WithDifferentlyCasedDisplayMode_ResolvesTheSameMode(string displayMode, string expected) + { + Assert.AreEqual(expected, RenderMoney("1234.56", "USD", displayMode)); + } + + [TestMethod] + public void FormatMoney_WithNullAmount_RendersZero() + { + var template = new Template("${ formatMoney(Amount, 'USD') }"); + + var result = template.Render( + new CompositeModelValue(new ModelField("Amount", (string)null))); + + Assert.AreEqual("$0.00", result); + } + + [TestMethod] + public void FormatMoney_WithNullDisplayMode_FallsBackToNarrowSymbol() + { + var template = new Template("${ formatMoney(Amount, 'USD', DisplayMode) }"); + + var result = template.Render( + new CompositeModelValue( + new ModelField("Amount", 1234.56m), + new ModelField("DisplayMode", (string)null))); + + Assert.AreEqual("$1,234.56", result); + } + + [TestMethod] + public void FormatMoney_WithoutCurrencyCode_RendersBareAmount() + { + var template = new Template("${ formatMoney(Amount, Currency) }"); + + var result = template.Render( + new CompositeModelValue( + new ModelField("Amount", 1234.56m), + new ModelField("Currency", (string)null))); + + Assert.AreEqual("1,234.56", result); + } + + [TestMethod] + [DataRow("1.005", "$1.01")] + [DataRow("2.005", "$2.01")] + [DataRow("-1.005", "-$1.01")] + public void FormatMoney_RoundsHalfAwayFromZero(string amount, string expected) + { + Assert.AreEqual(expected, RenderMoney(amount, "USD")); + } + + [TestMethod] + [DataRow("en-US")] + [DataRow("ru-RU")] + [DataRow("de-DE")] + public void FormatMoney_IsIndependentOfRenderCulture(string locale) + { + var template = new Template("${ formatMoney(Amount, 'USD') }${ formatMoney(Amount, 'EUR') }"); + + var result = template.Render( + new CompositeModelValue(new ModelField("Amount", 1234.56m)), + new RenderSettings { CultureInfo = new CultureInfo(locale) }); + + Assert.AreEqual("$1,234.56" + "€1,234.56", result); + } + + [TestMethod] + public void FormatMoney_SeparatesWithPlainSpaces_SoSmsStaysInTheGsmAlphabet() + { + var result = RenderMoney("1234.56", "CHF"); + + Assert.AreEqual("Fr. 1,234.56", result); + Assert.IsFalse(result.Contains('\u00A0')); + } + + [TestMethod] + public void FormatMoney_WithDisplayModeFromModel_FallsBackToNarrowSymbolWhenUnsupported() + { + var template = new Template("${ formatMoney(Amount, 'USD', DisplayMode) }"); + + var result = template.Render( + new CompositeModelValue( + new ModelField("Amount", 1234.56m), + new ModelField("DisplayMode", "fancy"))); + + Assert.AreEqual("$1,234.56", result); + } + + [TestMethod] + [ExpectedException(typeof(TemplateContainsErrorsException))] + public void FormatMoney_WithUnsupportedConstantDisplayMode_ReportsStaticError() + { + new Template("${ formatMoney(Amount, 'USD', 'fancy') }"); + } + + private static string RenderMoney(string amount, string currencyCode, string displayMode = null) + { + var displayModeArgument = displayMode == null ? string.Empty : $", '{displayMode}'"; + var template = new Template($"${{ formatMoney(Amount, '{currencyCode}'{displayModeArgument}) }}"); + + return template.Render( + new CompositeModelValue( + new ModelField("Amount", decimal.Parse(amount, CultureInfo.InvariantCulture)))); + } + } +}