├── docs ├── CNAME ├── favicon.ico ├── logo_nodamoney.png └── _config.yml ├── .github ├── FUNDING.yml └── workflows │ ├── release.yml │ ├── pull-request.yml │ └── benchmark.yml ├── global.json ├── tests ├── NodaMoney.Tests │ ├── Serialization │ │ ├── Order.cs │ │ ├── RavenDbSerializationSpec │ │ │ ├── SampleData.cs │ │ │ └── StoreInRavenDb.cs │ │ ├── NullableOrder.cs │ │ ├── NewtonsoftJsonSerializerSpec │ │ │ └── SerializeCurrency.cs │ │ ├── SystemTextJsonSerializationSpec │ │ │ ├── SerializeCurrency.cs │ │ │ └── DeserializeMoney.cs │ │ ├── ValidJsonV2TestData.cs │ │ ├── InvalidJsonV2TestData.cs │ │ ├── NestedJsonV2TestData.cs │ │ ├── XmlSerializationSpec │ │ │ ├── SerializeCurrencyWithXmlSerializer.cs │ │ │ └── XmlSerializationHelper.cs │ │ ├── InvalidJsonV1TestData.cs │ │ ├── NestedJsonV1TestData.cs │ │ ├── DataContractSerializerSpec │ │ │ └── SerializeMoney.cs │ │ └── BinaryFormatterSpec │ │ │ └── SerializeMoney.cs │ ├── Iso4127Spec │ │ ├── Iso4127Currency.cs │ │ └── Iso4127ListFixture.cs │ ├── MoneySpec │ │ ├── DefaultMoney.cs │ │ ├── DeconstructMoney.cs │ │ ├── CreateMoneyWithDoubleValue.cs │ │ ├── CreateMoneyWithDifferentRounding.cs │ │ ├── MoneyInMalagasyAriaryWhichHasFiveSubunits.cs │ │ └── MoneyImplicit.cs │ ├── CurrencySpec │ │ ├── DeconstructCurrency.cs │ │ ├── DefaultCurrency.cs │ │ ├── CompareCurrencies.cs │ │ ├── CurrencyFromIsoCode.cs │ │ └── CreateInternalCurrency.cs │ ├── FastMoneySpec │ │ ├── DeconstructMoney.cs │ │ ├── DefaultMoney.cs │ │ ├── CreateMoneyWithDoubleValue.cs │ │ ├── CreateFromMoney.cs │ │ ├── MoneyImplicit.cs │ │ └── CreateFastMoneyWithMinAndMaxValues.cs │ ├── CurrencyInfoSpec │ │ ├── CreateAsFormatProvider.cs │ │ ├── CurrentCurrency.cs │ │ ├── CurrencyInfoTryFromCode.cs │ │ ├── CurrencyInfoFromCode.cs │ │ ├── InitiateInternallyACurrency.cs │ │ ├── ValidateTheDateRange.cs │ │ └── CurrencyFromRegionOrCulture.cs │ ├── Helpers │ │ └── NoParallelization.cs │ ├── ExchangeRateSpec │ │ ├── DeconstructExchangeRate.cs │ │ ├── ConvertExchangeRateToString.cs │ │ ├── ConvertMoney.cs │ │ ├── CompareExchangeRates.cs │ │ └── CreateAnExchangeRateWithCurrenciesAsStrings.cs │ ├── MoneyConvertibleSpec │ │ ├── ConvertMoneyToNumericType.cs │ │ └── ExplicitCastMoneyToNumericType.cs │ ├── MoneyUnaryOperatorsSpec │ │ ├── AddAndSubtractMoneyUnary.cs │ │ └── IncrementAndDecrementMoneyUnary.cs │ ├── MoneyParsableSpec │ │ ├── ParseMoneyWithMoreDecimalPossibleForCurrency.cs │ │ ├── ParseAllCurrencySymbolsAndCodes.cs │ │ └── ParseNegativeMoney.cs │ ├── MoneyRoundingSpec │ │ ├── ApplyStandardRounding.cs │ │ ├── ApplyMaxScale.cs │ │ └── MoneyCalculations.cs │ ├── MoneyFiveMostUsedCurrenciesSpec │ │ ├── CreateEuros.cs │ │ ├── CreateYens.cs │ │ ├── CreateDollars.cs │ │ ├── CreateYuan.cs │ │ └── CreatePonds.cs │ ├── MoneyFormattableSpec │ │ └── CurrencySymbolSpecifier.cs │ ├── NodaMoney.Tests.csproj │ └── MoneyNumericInterfaces │ │ └── NumericOperations.cs ├── Benchmark │ ├── Benchmark.csproj │ ├── InitializingCurrencyBenchmarks.cs │ ├── BenchmarkDotNet.Artifacts │ │ └── results │ │ │ ├── Benchmark.InitializingCurrencyBenchmarks-report-github.md │ │ │ ├── Benchmark.MoneyParsingBenchmarks-report-github.md │ │ │ ├── Benchmark.HighLoadBenchmarks-report-github.md │ │ │ ├── Benchmark.MoneyFormattingBenchmarks-report-github.md │ │ │ ├── Benchmark.InitializingMoneyBenchmarks-report-github.md │ │ │ └── Benchmark.MoneyOperationsBenchmarks-report-github.md │ ├── Program.cs │ ├── MoneyFormattingBenchmarks.cs │ ├── MoneyParsingBenchmarks.cs │ ├── MoneyConvertingBenchmarks.cs │ ├── MoneyEqualBenchmarks.cs │ ├── InitializingMoneyBenchmarks.cs │ ├── MoneyOperationsBenchmarks.cs │ └── HighLoadBenchmarks.cs └── NodaMoney.DependencyInjection.Tests │ └── NodaMoney.DependencyInjection.Tests.csproj ├── .gitignore ├── src └── NodaMoney │ ├── Context │ ├── CashDenominationRounding.cs │ ├── NoRounding.cs │ ├── StandardRounding.cs │ └── MoneyContextIndex.cs │ ├── Transaction.cs │ ├── CustomDictionary.xml │ ├── DotNetCompatibility.cs │ ├── ExtendedMoney.cs │ ├── Serialization │ └── CurrencyJsonConverter.cs │ ├── MinorUnit.cs │ ├── InvalidCurrencyException.cs │ ├── MoneyContextMismatchException.cs │ ├── Currency.Serializable.cs │ └── Price.cs ├── NodaMoney.slnx ├── features ├── cldr │ ├── feature-roadmap.md │ ├── feature-aot-trimming.md │ ├── feature-examples.md │ ├── feature-migration-versioning.md │ ├── feature-testing-strategy.md │ ├── feature-di-and-configuration.md │ ├── feature-parsing.md │ ├── feature-data-generation.md │ ├── feature-formatting.md │ ├── feature-provider-model.md │ └── feature-api-surface.md └── proposals │ ├── 15-temporal-aspects-for-rates-and-rounding.md │ ├── 11-validation-policies-for-zero-amounts-and-currency-checks.md │ ├── 10-culture-locale-sensitive-defaults-and-policies.md │ ├── 09-context-equality-identity-and-pooling.md │ ├── 13-precision-scale-governance-and-trailing-zero-policy.md │ ├── 16-test-kits-and-property-based-tests.md │ ├── 05-big-unscaled-amount-type.md │ ├── 14-deterministic-arithmetic-across-types.md │ ├── 12-serialization-ecosystem-completeness.md │ ├── 02-cash-rounding-and-denomination-policies.md │ ├── 07-monetary-operators-and-queries.md │ ├── 06-allocation-proration-apis.md │ ├── 08-provider-based-discovery-and-di-integration.md │ ├── 03-ambiguous-currency-symbol-policies.md │ ├── rounding-strategy-vs-provider-and-operators.md │ ├── split-design.md │ ├── 01-rounding-provider-ecosystem.md │ └── 04-exchange-rate-provider-model-and-conversion-context.md └── CONTRIBUTING.md /docs/CNAME: -------------------------------------------------------------------------------- 1 | www.nodamoney.org -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: RemyDuijkeren 2 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RemyDuijkeren/NodaMoney/HEAD/docs/favicon.ico -------------------------------------------------------------------------------- /docs/logo_nodamoney.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RemyDuijkeren/NodaMoney/HEAD/docs/logo_nodamoney.png -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "rollForward": "latestMajor", 4 | "allowPrerelease": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/Serialization/Order.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.Serialization; 2 | 3 | [Serializable] 4 | public class Order 5 | { 6 | public int Id { get; set; } 7 | public Money Total { get; set; } 8 | public string Name { get; set; } 9 | } 10 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/Serialization/RavenDbSerializationSpec/SampleData.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.Serialization.RavenDbSerializationSpec; 2 | 3 | public class SampleData 4 | { 5 | public string Name { get; set; } 6 | 7 | public Money Price { get; set; } 8 | 9 | public Currency BaseCurrency { get; set; } 10 | } 11 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/Serialization/NullableOrder.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.Serialization; 2 | 3 | [Serializable] 4 | public class NullableOrder 5 | { 6 | public int Id { get; set; } 7 | 8 | //[JsonConverter(typeof(NullableMoneyJsonConverter))] 9 | public Money? Total { get; set; } 10 | public string Name { get; set; } 11 | } 12 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/Iso4127Spec/Iso4127Currency.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.Iso4127Spec; 2 | 3 | public class Iso4127Currency 4 | { 5 | public string CountryName { get; set; } 6 | public string CurrencyName { get; set; } 7 | public string Currency { get; set; } 8 | public string CurrencyNumber { get; set; } 9 | public string CurrencyMinorUnits { get; set; } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # User-specific files or directories 2 | *.suo 3 | *.user 4 | .vs 5 | .vscode 6 | 7 | # Build results 8 | [Bb]in/ 9 | [Oo]bj/ 10 | [Aa]rtifacts/ 11 | 12 | # ReSharper is a .NET coding add-in 13 | _ReSharper*/ 14 | /_ReSharper.Caches/ 15 | *.[Rr]e[Ss]harper 16 | *.DotSettings.user 17 | 18 | # JetBrains Rider IDE 19 | .idea/ 20 | riderModule.iml 21 | 22 | # Benchmark log files 23 | BenchmarkDotNet.Artifacts/ 24 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneySpec/DefaultMoney.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.MoneySpec; 2 | 3 | public class DefaultMoney 4 | { 5 | [Fact] 6 | public void WhenCreatingDefault_ThenItShouldBeNoCurrency() 7 | { 8 | Money money = default; 9 | 10 | money.Should().NotBeNull(); 11 | money.Currency.Should().Be(default(Currency)); 12 | money.Amount.Should().Be(default(decimal)); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneySpec/DeconstructMoney.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.MoneySpec; 2 | 3 | public class DeconstructMoney 4 | { 5 | [Fact] 6 | public void WhenDeConstructing_ThenShouldSucceed() 7 | { 8 | var money = new Money(10m, "EUR"); 9 | 10 | var (amount, currency) = money; 11 | 12 | amount.Should().Be(10m); 13 | currency.Should().Be(Currency.FromCode("EUR")); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/CurrencySpec/DeconstructCurrency.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.CurrencySpec; 2 | 3 | public class DeconstructCurrency 4 | { 5 | [Fact] 6 | public void WhenDeConstructing_ThenShouldSucceed() 7 | { 8 | Currency currency = CurrencyInfo.FromCode("EUR"); 9 | 10 | var (code, symbol) = currency; 11 | 12 | code.Should().Be("EUR"); 13 | symbol.Should().Be("€"); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/FastMoneySpec/DeconstructMoney.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.FastMoneySpec; 2 | 3 | public class DeconstructMoney 4 | { 5 | [Fact] 6 | public void WhenDeConstructing_ThenShouldSucceed() 7 | { 8 | var money = new FastMoney(10m, "EUR"); 9 | 10 | var (amount, currency) = money; 11 | 12 | amount.Should().Be(10m); 13 | currency.Should().Be(Currency.FromCode("EUR")); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/FastMoneySpec/DefaultMoney.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.FastMoneySpec; 2 | 3 | public class DefaultMoney 4 | { 5 | [Fact] 6 | public void WhenCreatingDefault_ThenItShouldBeNoCurrency() 7 | { 8 | FastMoney money = default; 9 | 10 | money.Should().NotBeNull(); 11 | money.Currency.Should().Be(default(Currency)); 12 | money.Amount.Should().Be(default(decimal)); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/CurrencyInfoSpec/CreateAsFormatProvider.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.CurrencyInfoSpec; 2 | 3 | public class CreateAsFormatProvider 4 | { 5 | [Fact] 6 | public void WhenCreateCurrencyInfo_ShouldBeFormatProvider() 7 | { 8 | // Act 9 | CurrencyInfo currencyInfo = CurrencyInfo.FromCode("EUR"); 10 | 11 | // Assert 12 | currencyInfo.Should().BeAssignableTo(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | title: NodaMoney 2 | description: NodaMoney provides a library that treats Money as a first class citizen and handles all the ugly bits like currencies and formatting. 3 | remote_theme: pages-themes/cayman@v0.2.0 4 | #remote_theme: pages-themes/minimal@v0.2.0 5 | #remote_theme: pages-themes/slate@v0.2.0 6 | #remote_theme: pages-themes/tactile@v0.2.0 7 | plugins: 8 | - jekyll-remote-theme 9 | show_downloads: true 10 | google_analytics: UA-83952219-1 11 | #highlighter: rouge 12 | -------------------------------------------------------------------------------- /src/NodaMoney/Context/CashDenominationRounding.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | namespace NodaMoney.Context; 4 | 5 | internal record CashDenominationRounding : IRoundingStrategy 6 | { 7 | public CashDenominationRounding(decimal decimals) 8 | { 9 | throw new NotImplementedException(); 10 | } 11 | 12 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 13 | public decimal Round(decimal amount, CurrencyInfo currencyInfo, int? decimals) => throw new NotImplementedException(); 14 | } 15 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/Helpers/NoParallelization.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.Helpers; 2 | 3 | [CollectionDefinition(nameof(NoParallelization), DisableParallelization = true)] 4 | public class NoParallelization 5 | { 6 | // Place [Collection(nameof(NoParallelization))] as an attribute on a test class, and it will become a parallel-disabled test 7 | // collection. Parallel-capable test collections will be run first (in parallel), followed by parallel-disabled test 8 | // collections (run sequentially). See https://xunit.net/docs/running-tests-in-parallel.html for more info. 9 | } 10 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/ExchangeRateSpec/DeconstructExchangeRate.cs: -------------------------------------------------------------------------------- 1 | using NodaMoney.Exchange; 2 | 3 | namespace NodaMoney.Tests.ExchangeRateSpec; 4 | 5 | public class DeconstructExchangeRate 6 | { 7 | [Fact] 8 | public void WhenDeconstruct_ThenShouldSucceed() 9 | { 10 | var fx = new ExchangeRate(Currency.FromCode("EUR"), Currency.FromCode("USD"), 1.2591m); 11 | 12 | var (baseCurrency, quoteCurrency, rate) = fx; 13 | 14 | rate.Should().Be(1.2591m); 15 | baseCurrency.Should().Be(Currency.FromCode("EUR")); 16 | quoteCurrency.Should().Be(Currency.FromCode("USD")); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/Benchmark/Benchmark.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net10.0 6 | enable 7 | enable 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /tests/Benchmark/InitializingCurrencyBenchmarks.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Attributes; 2 | using NodaMoney; 3 | 4 | namespace Benchmark; 5 | 6 | [MemoryDiagnoser] 7 | public class InitializingCurrencyBenchmarks 8 | { 9 | [Benchmark] 10 | public Currency CurrencyFromCode() 11 | { 12 | Currency currency = CurrencyInfo.FromCode("EUR"); 13 | return currency; 14 | } 15 | 16 | [Benchmark] 17 | public CurrencyInfo CurrencyInfoFromCode() 18 | { 19 | CurrencyInfo currency = CurrencyInfo.FromCode("EUR"); 20 | return currency; 21 | } 22 | 23 | [Benchmark] 24 | public CurrencyInfo CurrencyInfoTryFromCode() 25 | { 26 | bool result = CurrencyInfo.TryFromCode("EUR", out CurrencyInfo currency); 27 | return currency; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/NodaMoney/Transaction.cs: -------------------------------------------------------------------------------- 1 | using NodaMoney.Exchange; 2 | 3 | namespace NodaMoney; 4 | 5 | /// Represents a general transaction done in a web shop. 6 | public class Transaction 7 | { 8 | /// Gets or sets the transaction amount of the transaction in the local currency of the customer. 9 | public Money Amount { get; set; } 10 | 11 | /// Gets or sets the exchange rate to the base currency of the shop at the time of the transaction. 12 | public ExchangeRate ExchangeRate { get; set; } 13 | 14 | /// Gets or sets the tax amount of the transaction. 15 | public Money Tax { get; set; } 16 | 17 | /// Gets or sets the optional discount of the transaction. 18 | public Money Discount { get; set; } 19 | } 20 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneySpec/CreateMoneyWithDoubleValue.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.MoneySpec; 2 | 3 | public class CreateMoneyWithDoubleValue 4 | { 5 | [Theory] 6 | [InlineData(0.03, 0.03)] 7 | [InlineData(0.3333333333333333, 0.33)] 8 | public void WhenValueIsDoubleAndWithCurrency_ThenMoneyShouldBeCorrect(double input, decimal expected) 9 | { 10 | var money = new Money(input, "EUR"); 11 | 12 | money.Amount.Should().Be(expected); 13 | } 14 | 15 | [Theory] 16 | [InlineData(0.03, 0.03)] 17 | [InlineData(0.3333333333333333, 0.33)] 18 | public void WhenValueIsDoubleWithoutCurrency_ThenMoneyShouldBeCorrect(double input, decimal expected) 19 | { 20 | var money = new Money(input, "EUR"); 21 | 22 | money.Amount.Should().Be(expected); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/FastMoneySpec/CreateMoneyWithDoubleValue.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.FastMoneySpec; 2 | 3 | public class CreateMoneyWithDoubleValue 4 | { 5 | [Theory] 6 | [InlineData(0.03, 0.03)] 7 | [InlineData(0.3333333333333333, 0.3333)] 8 | public void WhenValueIsDoubleAndWithCurrency_ThenMoneyShouldBeCorrect(double input, decimal expected) 9 | { 10 | var money = new FastMoney(input, "EUR"); 11 | 12 | money.Amount.Should().Be(expected); 13 | } 14 | 15 | [Theory] 16 | [InlineData(0.03, 0.03)] 17 | [InlineData(0.3333333333333333, 0.3333)] 18 | public void WhenValueIsDoubleWithoutCurrency_ThenMoneyShouldBeCorrect(double input, decimal expected) 19 | { 20 | var money = new FastMoney(input, "EUR"); 21 | 22 | money.Amount.Should().Be(expected); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/NodaMoney/CustomDictionary.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | cb 6 | 7 | 8 | Noda 9 | iraimbilanja 10 | cinquième 11 | ariary 12 | Ldml 13 | 14 | 15 | ComPlus 16 | 17 | 18 | 19 | 20 | USDollar 21 | ISO 22 | Json 23 | OACurrency 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /tests/Benchmark/BenchmarkDotNet.Artifacts/results/Benchmark.InitializingCurrencyBenchmarks-report-github.md: -------------------------------------------------------------------------------- 1 | ``` 2 | 3 | BenchmarkDotNet v0.15.6, Windows 11 (10.0.26200.7171) 4 | AMD Ryzen 7 5800H with Radeon Graphics 3.20GHz, 1 CPU, 16 logical and 8 physical cores 5 | .NET SDK 10.0.100 6 | [Host] : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3 7 | DefaultJob : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3 8 | 9 | 10 | ``` 11 | | Method | Mean | Error | Op/s | Allocated | 12 | |------------------------ |----------:|----------:|--------------:|----------:| 13 | | CurrencyFromCode | 12.229 ns | 0.1748 ns | 81,774,812.1 | - | 14 | | CurrencyInfoFromCode | 6.611 ns | 0.1740 ns | 151,264,622.9 | - | 15 | | CurrencyInfoTryFromCode | 6.052 ns | 0.1095 ns | 165,230,528.0 | - | 16 | -------------------------------------------------------------------------------- /src/NodaMoney/Context/NoRounding.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | namespace NodaMoney.Context; 4 | 5 | /// Represents a no-rounding strategy for monetary calculations. 6 | /// 7 | /// This class is used when no rounding is required in monetary operations. 8 | /// It returns the amount without applying any rounding logic. This can be 9 | /// useful in calculations where precision must be preserved exactly as provided 10 | /// or where rounding would lead to incorrect results in downstream processes. 11 | /// 12 | /// 13 | public record NoRounding : IRoundingStrategy 14 | { 15 | /// 16 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 17 | public decimal Round(decimal amount, CurrencyInfo currencyInfo, int? decimals) => amount; 18 | } 19 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/Serialization/NewtonsoftJsonSerializerSpec/SerializeCurrency.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace NodaMoney.Tests.Serialization.NewtonsoftJsonSerializerSpec; 4 | 5 | public class SerializeCurrency 6 | { 7 | [Theory] 8 | [InlineData("JPY")] 9 | [InlineData("EUR")] 10 | [InlineData("USD")] 11 | [InlineData("BHD")] 12 | [InlineData("BTC")] 13 | [InlineData("XXX")] 14 | public void WhenSerializingCurrency_ThenThisShouldSucceed(string code) 15 | { 16 | // Arrange 17 | var currency = Currency.FromCode(code); 18 | 19 | // Act 20 | string json = JsonConvert.SerializeObject(currency); 21 | 22 | // Assert 23 | json.Should().Be($"\"{code}\""); 24 | 25 | var clone = JsonConvert.DeserializeObject(json); 26 | clone.Should().Be(currency); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/Serialization/SystemTextJsonSerializationSpec/SerializeCurrency.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | 3 | namespace NodaMoney.Tests.Serialization.SystemTextJsonSerializationSpec; 4 | 5 | public class SerializeCurrency 6 | { 7 | [Theory] 8 | [InlineData("JPY")] 9 | [InlineData("EUR")] 10 | [InlineData("USD")] 11 | [InlineData("BHD")] 12 | [InlineData("BTC")] 13 | [InlineData("XXX")] 14 | public void WhenSerializingCurrency_ThenThisShouldSucceed(string code) 15 | { 16 | // Arrange 17 | var currency = Currency.FromCode(code); 18 | 19 | // Act 20 | string json = JsonSerializer.Serialize(currency); 21 | 22 | // Assert 23 | json.Should().Be($"\"{code}\""); 24 | 25 | var clone = JsonSerializer.Deserialize(json); 26 | clone.Should().Be(currency); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/Serialization/ValidJsonV2TestData.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.Serialization; 2 | 3 | public class ValidJsonV2TestData : TheoryData 4 | { 5 | public ValidJsonV2TestData() 6 | { 7 | Add("\"EUR 234.25\"", new Money(234.25m, CurrencyInfo.FromCode("EUR"))); 8 | Add("\"BTC 234.25\"", new Money(234.25m, CurrencyInfo.FromCode("BTC"))); 9 | //Add("\"EUR;CUSTOM 234.25\"", new Money(234.25m, CurrencyInfo.FromCode("EUR"))); 10 | //Add("\"EUR;ISO-4217 234.25\"", new Money(234.25m, CurrencyInfo.FromCode("EUR"))); 11 | //Add("\"EUR; 234.25\"", new Money(234.25m, CurrencyInfo.FromCode("EUR"))); 12 | 13 | // member reversed 14 | Add("\"234.25 EUR\"", new Money(234.25m, CurrencyInfo.FromCode("EUR"))); 15 | Add("\"234.25 BTC\"", new Money(234.25m, CurrencyInfo.FromCode("BTC"))); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/FastMoneySpec/CreateFromMoney.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.FastMoneySpec; 2 | 3 | public class CreateFromMoney 4 | { 5 | [Fact] 6 | public void WhenMoney() 7 | { 8 | // Arrange 9 | Money rounded = new Money(0.3333333333333333m, "EUR"); 10 | 11 | // Act 12 | var fast = new FastMoney(rounded); 13 | 14 | // Assert 15 | fast.Amount.Should().Be(0.33m); 16 | fast.Currency.Should().Be(rounded.Currency); 17 | } 18 | 19 | [Fact] 20 | public void WhenFromOACurrency() 21 | { 22 | // Arrange 23 | long cy = 123456789; 24 | 25 | // Act 26 | var fast = FastMoney.FromOACurrency(cy, Currency.FromCode("EUR")); 27 | 28 | // Assert 29 | fast.Amount.Should().Be(12345.6789m); 30 | fast.Currency.Should().Be(Currency.FromCode("EUR")); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/Serialization/InvalidJsonV2TestData.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.Serialization; 2 | 3 | public class InvalidJsonV2TestData : TheoryData 4 | { 5 | public InvalidJsonV2TestData() 6 | { 7 | Add("\"EUR\""); // No Amount member 8 | Add("\"234.25\""); // No Currency member 9 | Add("\"EUR234.25\""); // No hard space 10 | Add("\"234.25EUR\""); // member reversed and no hard space 11 | Add("\"EUR 234.25 123\""); // Extra member 12 | Add("\"EUR 234.25 EUR\""); // Extra member 13 | Add("\"EUR 234.25 123 EUR\""); // Extra member 14 | Add("\"EUR 234.25 EUR 123\""); // Extra member 15 | Add("\"EUR EUR\""); // duplicate currency 16 | Add("\"234.25 234.25\""); // duplicate members 17 | Add("EUR 234.25"); // no quotes 18 | //Add("'EUR 234.25'"); // single quotes => works for Newtonsoft.Json 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | on: 5 | push: 6 | tags: 7 | - '[0-9]+.[0-9]+.[0-9]+' 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | uses: ./.github/workflows/ci.yml 13 | 14 | release: 15 | needs: build 16 | runs-on: ubuntu-latest 17 | permissions: 18 | checks: write 19 | packages: read 20 | 21 | steps: 22 | - name: 🛠️ Setup .NET 23 | uses: actions/setup-dotnet@v5 24 | with: 25 | dotnet-version: 10.0.x 26 | 27 | - name: 📥 Download Packages 28 | uses: actions/download-artifact@v6 29 | with: 30 | name: Packages 31 | path: ./pkg/ 32 | 33 | - name: 🚀 Push to NuGet 34 | run: | 35 | dotnet nuget push "./pkg/**/*.nupkg" "./pkg/**/*.snupkg" \ 36 | --api-key ${{ secrets.NUGET_API_KEY }} \ 37 | --source https://api.nuget.org/v3/index.json \ 38 | --skip-duplicate 39 | -------------------------------------------------------------------------------- /src/NodaMoney/DotNetCompatibility.cs: -------------------------------------------------------------------------------- 1 | // Declaring the assembly as CLS-compliant 2 | [assembly: CLSCompliant(true)] 3 | 4 | #if !NET5_0_OR_GREATER 5 | // Init setters in C# 9 only work from .NET 5 and higher, see https://www.mking.net/blog/error-cs0518-isexternalinit-not-defined 6 | namespace System.Runtime.CompilerServices 7 | { 8 | [ComponentModel.EditorBrowsable(ComponentModel.EditorBrowsableState.Never)] 9 | internal static class IsExternalInit; 10 | } 11 | 12 | #endif 13 | 14 | #if !NETCOREAPP3_0_OR_GREATER && !NETSTANDARD2_1_OR_GREATER 15 | // NotNullWhen attribute is introduced in .NET Core 3.0 16 | namespace System.Diagnostics.CodeAnalysis 17 | { 18 | [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] 19 | internal sealed class NotNullWhenAttribute : Attribute 20 | { 21 | public NotNullWhenAttribute(bool returnValue) 22 | { 23 | ReturnValue = returnValue; 24 | } 25 | 26 | public bool ReturnValue { get; } 27 | } 28 | } 29 | #endif 30 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/CurrencyInfoSpec/CurrentCurrency.cs: -------------------------------------------------------------------------------- 1 | using NodaMoney.Tests.Helpers; 2 | 3 | namespace NodaMoney.Tests.CurrencyInfoSpec; 4 | 5 | [Collection(nameof(NoParallelization))] 6 | public class CurrentCurrency 7 | { 8 | [Fact] 9 | [UseCulture("en-US")] 10 | public void WhenCurrentCultureIsUS_ThenCurrencyIsDollar() 11 | { 12 | var currency = CurrencyInfo.CurrentCurrency; 13 | 14 | currency.Should().Be(CurrencyInfo.FromCode("USD")); 15 | } 16 | 17 | [Fact] 18 | [UseCulture("nl-NL")] 19 | public void WhenCurrentCultureIsNL_ThenCurrencyIsEuro() 20 | { 21 | var currency = CurrencyInfo.CurrentCurrency; 22 | 23 | currency.Should().Be(CurrencyInfo.FromCode("EUR")); 24 | } 25 | 26 | [Fact] 27 | [UseCulture(null)] 28 | public void WhenCurrentCultureIsInvariant_ThenCurrencyIsDefault() 29 | { 30 | var currency = CurrencyInfo.CurrentCurrency; 31 | 32 | currency.Should().Be(CurrencyInfo.NoCurrency); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneyConvertibleSpec/ConvertMoneyToNumericType.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.MoneyConvertibleSpec; 2 | 3 | public class ConvertMoneyToNumericType 4 | { 5 | readonly Money _euros = new Money(765.43m, "EUR"); 6 | 7 | [Fact] 8 | public void WhenConvertingToDecimal_ThenThisShouldSucceed() 9 | { 10 | var result = _euros.ToDecimal(); 11 | 12 | result.Should().Be(765.43m); 13 | } 14 | 15 | [Fact] 16 | public void WhenConvertingToDouble_ThenThisShouldSucceed() 17 | { 18 | var result = _euros.ToDouble(); 19 | 20 | result.Should().BeApproximately(765.43d, 0.001d); 21 | } 22 | 23 | [Fact] 24 | public void WhenConvertingToLong_ThenThisShouldSucceed() 25 | { 26 | var result = _euros.ToInt64(); 27 | 28 | result.Should().Be(765); 29 | } 30 | 31 | [Fact] 32 | public void WhenConvertingToInt_ThenThisShouldSucceed() 33 | { 34 | var result = _euros.ToInt32(); 35 | 36 | result.Should().Be(765); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/Serialization/NestedJsonV2TestData.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.Serialization; 2 | 3 | public class NestedJsonV2TestData : TheoryData 4 | { 5 | public NestedJsonV2TestData() 6 | { 7 | var order = new Order 8 | { 9 | Id = 123, 10 | Name = "Abc", 11 | Total = new Money(234.25m, CurrencyInfo.FromCode("EUR")) 12 | }; 13 | 14 | Add("""{ "Id": 123, "Name": "Abc", "Total": "EUR 234.25" }""", order); 15 | Add("""{ "id": 123, "name": "Abc", "total": "EUR 234.25" }""", order); // camelCase (System.Text.Json needs PropertyNameCaseInsensitive = true to work) 16 | 17 | // // Discount explicit null 18 | // Add("{ \"Id\": 123, \"Name\": \"Abc\", \"Price\": { \"Amount\": 234.25, \"Currency\": \"EUR\" }, \"Discount\": null }", order); // Amount as number 19 | // Add("{ \"Id\": 123, \"Name\": \"Abc\", \"Price\": { \"Amount\": \"234.25\", \"Currency\": \"EUR\" }, \"Discount\": null }", order); // Amount as string 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneyUnaryOperatorsSpec/AddAndSubtractMoneyUnary.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.MoneyUnaryOperatorsSpec; 2 | 3 | public class AddAndSubtractMoneyUnary 4 | { 5 | private readonly Money _tenEuroPlus = new Money(10.00m, "EUR"); 6 | private readonly Money _tenEuroMin = new Money(-10.00m, "EUR"); 7 | 8 | [Fact] 9 | public void WhenUsingUnaryPlusOperator_ThenThisSucceed() 10 | { 11 | var r1 = +_tenEuroPlus; 12 | var r2 = +_tenEuroMin; 13 | 14 | r1.Amount.Should().Be(10.00m); 15 | r1.Currency.Code.Should().Be("EUR"); 16 | r2.Amount.Should().Be(-10.00m); 17 | r2.Currency.Code.Should().Be("EUR"); 18 | } 19 | 20 | [Fact] 21 | public void WhenUsingUnaryMinOperator_ThenThisSucceed() 22 | { 23 | var r1 = -_tenEuroPlus; 24 | var r2 = -_tenEuroMin; 25 | 26 | r1.Amount.Should().Be(-10.00m); 27 | r1.Currency.Code.Should().Be("EUR"); 28 | r2.Amount.Should().Be(10.00m); 29 | r2.Currency.Code.Should().Be("EUR"); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Benchmark/BenchmarkDotNet.Artifacts/results/Benchmark.MoneyParsingBenchmarks-report-github.md: -------------------------------------------------------------------------------- 1 | ``` 2 | 3 | BenchmarkDotNet v0.15.6, Windows 11 (10.0.26200.7171) 4 | AMD Ryzen 7 5800H with Radeon Graphics 3.20GHz, 1 CPU, 16 logical and 8 physical cores 5 | .NET SDK 10.0.100 6 | [Host] : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3 7 | DefaultJob : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3 8 | 9 | 10 | ``` 11 | | Method | Mean | Error | Op/s | Gen0 | Allocated | 12 | |------------------ |---------:|--------:|------------:|-------:|----------:| 13 | | Implicit | 155.8 ns | 1.68 ns | 6,419,477.6 | 0.0191 | 160 B | 14 | | ImplicitTry | 184.7 ns | 3.47 ns | 5,412,967.7 | 0.0191 | 160 B | 15 | | Explicit | 159.3 ns | 1.22 ns | 6,279,071.2 | 0.0191 | 160 B | 16 | | ExplicitAsSpan | 181.6 ns | 0.66 ns | 5,507,450.0 | 0.0191 | 160 B | 17 | | ExplicitTry | 167.6 ns | 2.89 ns | 5,967,989.4 | 0.0191 | 160 B | 18 | | ExplicitTryAsSpan | 165.3 ns | 1.93 ns | 6,051,351.3 | 0.0191 | 160 B | 19 | -------------------------------------------------------------------------------- /NodaMoney.slnx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/Benchmark/Program.cs: -------------------------------------------------------------------------------- 1 | using Benchmark; 2 | using BenchmarkDotNet.Columns; 3 | using BenchmarkDotNet.Configs; 4 | using BenchmarkDotNet.Diagnosers; 5 | using BenchmarkDotNet.Exporters; 6 | using BenchmarkDotNet.Jobs; 7 | using BenchmarkDotNet.Loggers; 8 | using BenchmarkDotNet.Running; 9 | 10 | // Configure BDN to export only GitHub-flavored Markdown 11 | var config = ManualConfig.CreateEmpty() 12 | .AddJob(Job.Default) 13 | .AddLogger(ConsoleLogger.Default) 14 | .AddExporter(MarkdownExporter.GitHub) 15 | .AddColumnProvider(DefaultColumnProviders.Instance) // add columns like Mean, Error, StdDev, etc. 16 | .HideColumns("StdDev", "Median", "RatioSD") 17 | .AddColumn(StatisticColumn.OperationsPerSecond) 18 | .AddDiagnoser(MemoryDiagnoser.Default); 19 | //.WithArtifactsPath(Path.GetFullPath("artifacts")); 20 | 21 | // Run all benchmarks in the assembly; honors command-line args (e.g., --runtimes) 22 | BenchmarkSwitcher.FromAssembly(typeof(HighLoadBenchmarks).Assembly).Run(args, config); 23 | -------------------------------------------------------------------------------- /features/cldr/feature-roadmap.md: -------------------------------------------------------------------------------- 1 | # Feature Proposal: Roadmap & Milestones (CLDR Formatting/Parsing) 2 | 3 | Status: proposal/spec 4 | Source: docs/feature-cldr-formatting-and-parsing.md 5 | 6 | ## Phase 1 — MVP 7 | - Locales: en-US, nl-NL, fr-FR, de-DE, ja-JP 8 | - Currencies: USD, EUR, CHF, JPY 9 | - Deliverables: 10 | - Data generator (codegen tables) 11 | - CldrMoneyLocalePatternProvider 12 | - Formatting: symbol placement, spacing, fraction digits, accounting sign 13 | - Tests: golden vs ICU, round-trips 14 | 15 | ## Phase 2 — Parsing & Coverage Expansion 16 | - Add parsing with token tries; ambiguous symbol policies 17 | - Expand locales and currencies; add Indian grouping 18 | - Integrate UseCashDigits with MoneyContext rounding 19 | 20 | ## Phase 3 — Optimization & Options 21 | - Perf and allocation tuning; caching compiled patterns 22 | - Optional binary data format; feature flags for including currency names 23 | 24 | ## Phase 4 — Ecosystem Options 25 | - Optional ICU4N adapter package 26 | - DI ergonomics and configuration binding samples 27 | 28 | ## Phase 5 — Automation & Release 29 | - CI job to regenerate CLDR data on-demand 30 | - Expose CldrInfo.Version; document update cadence and changelog 31 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneyParsableSpec/ParseMoneyWithMoreDecimalPossibleForCurrency.cs: -------------------------------------------------------------------------------- 1 | using NodaMoney.Tests.Helpers; 2 | 3 | namespace NodaMoney.Tests.MoneyParsableSpec; 4 | 5 | [Collection(nameof(NoParallelization))] 6 | public class ParseMoneyWithMoreDecimalPossibleForCurrency 7 | { 8 | [Fact, UseCulture("ja-JP")] 9 | public void WhenParsingJapaneseYen_ThenThisShouldBeRoundedDown() 10 | { 11 | var yen = Money.Parse("¥ 98,765.4"); 12 | 13 | yen.Should().Be(new Money(98_765m, "JPY")); 14 | } 15 | 16 | [Fact, UseCulture("ja-JP")] 17 | public void WhenParsingJapaneseYen_ThenThisShouldBeRoundedUp() 18 | { 19 | var yen = Money.Parse("¥ 98,765.5"); 20 | 21 | yen.Should().Be(new Money(98_766m, "JPY")); 22 | } 23 | 24 | [Fact, UseCulture("de-CH")] 25 | public void WhenParsingSwissFranc_ThenThisShouldBeRoundedUp() 26 | { 27 | // CHF 98.765,45 : Period (.) as the thousands separator (common in everyday usage) 28 | // CHF 98’765.45 : Apostrophe (’) as the thousands separator (formal and financial contexts) 29 | 30 | var money = Money.Parse("CHF 98’765.475"); 31 | 32 | money.Should().Be(new Money(98765.48m, "CHF")); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/NodaMoney/ExtendedMoney.cs: -------------------------------------------------------------------------------- 1 | using NodaMoney.Exchange; 2 | 3 | namespace NodaMoney; 4 | 5 | /// Represents an extended monetary value with an associated exchange rate and transaction date. 6 | /// 7 | /// https://hillside.net/plop/2015/papers/penguins/8.pdf , 8 | /// Dynamics 365 https://learn.microsoft.com/en-us/power-apps/developer/data-platform/transaction-currency-currency-entity 9 | /// https://community.dynamics.com/blogs/post/?postid=d09fbf59-9f0e-44dc-b902-23c9ae85e0d1 10 | /// 11 | /// The amount 12 | /// the exchange rate 13 | /// the transaction date 14 | internal readonly record struct ExtendedMoney(decimal Amount, ExchangeRate ExchangeRate, DateTimeOffset TransactionDate) 15 | { 16 | public Currency BaseCurrency => ExchangeRate.BaseCurrency; 17 | public decimal BaseAmount => ExchangeRate.Convert(new Money(Amount, ExchangeRate.QuoteCurrency)).Amount; 18 | public Currency Currency => ExchangeRate.QuoteCurrency; 19 | 20 | public ExtendedMoney(decimal amount, Currency currency) : 21 | this(amount, new ExchangeRate(currency, currency, 1), DateTimeOffset.Now) 22 | { 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /features/proposals/15-temporal-aspects-for-rates-and-rounding.md: -------------------------------------------------------------------------------- 1 | # Temporal Aspects for Rates and Rounding 2 | 3 | Why it matters 4 | - Rates and cash rounding policies change over time (VAT changes, denomination policies). Consumers often need historical/as-of queries. 5 | 6 | Proposal 7 | - Add effectiveFrom/effectiveTo semantics in exchange/rounding providers and include a DateTime/Instant in RoundingQuery and ConversionQuery to select historical policies. 8 | 9 | Possible implementation 10 | - Queries: 11 | - RoundingQuery includes DateTimeOffset? When. 12 | - ConversionQuery includes DateTimeOffset? At. 13 | - Providers: 14 | - IRoundingProvider and IExchangeRateProvider implementations select the appropriate snapshot by date. 15 | - Registries store time-bound entries; optionally, allow open-ended ranges. 16 | - Diagnostics: 17 | - RateContext carries AsOf timestamp; rounding strategies may expose provenance for audit. 18 | 19 | Risks / considerations 20 | - Time-zone semantics: prefer UTC; clearly document expectations. 21 | - Data volume/performance when storing historical snapshots. 22 | 23 | Open questions 24 | - Do we allow interpolation between dates or only exact snapshot selection? 25 | - Should MoneyContext expose a default AsOf for operations? 26 | -------------------------------------------------------------------------------- /tests/Benchmark/MoneyFormattingBenchmarks.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using BenchmarkDotNet.Attributes; 3 | using NodaMoney; 4 | 5 | namespace Benchmark; 6 | 7 | [MemoryDiagnoser] 8 | public class MoneyFormattingBenchmarks 9 | { 10 | readonly Money _euro = new Money(765.43m, "EUR"); 11 | readonly CultureInfo ci = new CultureInfo("nl-NL"); 12 | 13 | [Benchmark] 14 | public string DefaultFormat() 15 | { 16 | return _euro.ToString(); 17 | } 18 | 19 | [Benchmark] 20 | public string FormatWithPrecision() 21 | { 22 | return _euro.ToString("c2"); 23 | } 24 | 25 | [Benchmark] 26 | public string FormatProvider() 27 | { 28 | return _euro.ToString(ci); 29 | } 30 | 31 | [Benchmark] 32 | public string FormatWithPrecisionAndProvider() 33 | { 34 | return _euro.ToString("c2", ci); 35 | } 36 | 37 | [Benchmark] 38 | public string CompactFormat() 39 | { 40 | return _euro.ToString("K"); 41 | } 42 | 43 | [Benchmark] 44 | public string GeneralFormat() 45 | { 46 | return _euro.ToString("G"); 47 | } 48 | 49 | [Benchmark] 50 | public string RondTripFormat() 51 | { 52 | return _euro.ToString("R"); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/Serialization/XmlSerializationSpec/SerializeCurrencyWithXmlSerializer.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.Serialization.XmlSerializationSpec; 2 | 3 | public class SerializeCurrencyWithXmlSerializer : XmlSerializationHelper 4 | { 5 | readonly Currency _yen = CurrencyInfo.FromCode("JPY"); 6 | readonly Currency _euro = CurrencyInfo.FromCode("EUR"); 7 | readonly Currency _bitcoin = CurrencyInfo.FromCode("BTC"); 8 | 9 | [Fact] 10 | public void WhenSerializingYen_ThenThisShouldSucceed() 11 | { 12 | //Console.WriteLine(StreamToString(Serialize(yen))); 13 | var xml = SerializeToXml(_yen); 14 | 15 | _yen.Should().Be(Clone(_yen)); 16 | } 17 | 18 | [Fact] 19 | public void WhenSerializingEuro_ThenThisShouldSucceed() 20 | { 21 | //Console.WriteLine(StreamToString(Serialize(euro))); 22 | var xml = SerializeToXml(_euro); 23 | 24 | _euro.Should().Be(Clone(_euro)); 25 | } 26 | 27 | [Fact] 28 | public void WhenSerializingBitcoin_ThenThisShouldSucceed() 29 | { 30 | //Console.WriteLine(StreamToString(Serialize(euro))); 31 | var xml = SerializeToXml(_bitcoin); 32 | 33 | _bitcoin.Should().Be(Clone(_bitcoin)); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /features/proposals/11-validation-policies-for-zero-amounts-and-currency-checks.md: -------------------------------------------------------------------------------- 1 | # Validation Policies for Zero Amounts and Currency Checks 2 | 3 | Why it matters 4 | - Your TODO mentions strict vs relaxed zero-currency validation. Consistent policy across parsing, comparisons, and arithmetic avoids surprises. 5 | 6 | Proposal 7 | - Add ZeroCurrencyPolicy (Ignore | RequireMatch | InferDefault) and integrate with MoneyContext.EnforceZeroCurrencyMatching and parsing/formatting behaviors. 8 | 9 | Possible implementation 10 | - MoneyContextOptions: 11 | - ZeroCurrencyPolicy enum; EnforceZeroCurrencyMatching becomes a derived convenience. 12 | - Parsing: 13 | - When amount is zero and currency missing/ambiguous: follow policy (fail, infer DefaultCurrency, or ignore). 14 | - Comparisons/arithmetic: 15 | - For zero amounts across different currencies, allow comparisons or throw per policy. 16 | - Analyzer hints: 17 | - Optional Roslyn analyzer to flag ambiguous zero-currency operations in strict mode. 18 | 19 | Risks / considerations 20 | - Backward compatibility with existing zero handling. 21 | - Document behavior in docs/README.md clearly. 22 | 23 | Open questions 24 | - Should policy differ for serialization vs user input parsing? 25 | - Do we allow per-operation overrides? 26 | -------------------------------------------------------------------------------- /tests/Benchmark/BenchmarkDotNet.Artifacts/results/Benchmark.HighLoadBenchmarks-report-github.md: -------------------------------------------------------------------------------- 1 | ``` 2 | 3 | BenchmarkDotNet v0.15.6, Windows 11 (10.0.26200.7171) 4 | AMD Ryzen 7 5800H with Radeon Graphics 3.20GHz, 1 CPU, 16 logical and 8 physical cores 5 | .NET SDK 10.0.100 6 | [Host] : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3 7 | DefaultJob : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3 8 | 9 | 10 | ``` 11 | | Method | Mean | Error | Op/s | Ratio | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio | 12 | |------------------ |----------:|----------:|-------:|------:|---------:|---------:|---------:|----------:|------------:| 13 | | Create1MCurrency | 12.686 ms | 0.2494 ms | 78.83 | 0.43 | 484.3750 | 484.3750 | 484.3750 | 1.91 MB | 0.13 | 14 | | Create1MMoney | 29.750 ms | 0.2796 ms | 33.61 | 1.00 | 500.0000 | 500.0000 | 500.0000 | 15.26 MB | 1.00 | 15 | | Create1MFastMoney | 21.997 ms | 0.4164 ms | 45.46 | 0.74 | 500.0000 | 500.0000 | 500.0000 | 11.44 MB | 0.75 | 16 | | Create1MSqlMoney | 17.601 ms | 0.3084 ms | 56.81 | 0.59 | 500.0000 | 500.0000 | 500.0000 | 15.26 MB | 1.00 | 17 | | Create1MDecimal | 3.438 ms | 0.1078 ms | 290.87 | 0.12 | 500.0000 | 500.0000 | 500.0000 | 15.26 MB | 1.00 | 18 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/CurrencyInfoSpec/CurrencyInfoTryFromCode.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.CurrencyInfoSpec; 2 | 3 | public class CurrencyInfoTryFromCode 4 | { 5 | [Fact] 6 | public void WhenCodeExists_ReturnTrueAndCurrencyInfo() 7 | { 8 | // Arrange & Act 9 | var result = CurrencyInfo.TryFromCode("EUR", out CurrencyInfo currency); 10 | 11 | // Assert 12 | result.Should().BeTrue(); 13 | currency.Should().NotBeNull(); 14 | currency!.Symbol.Should().Be("€"); 15 | currency.Code.Should().Be("EUR"); 16 | currency.EnglishName.Should().Be("Euro"); 17 | currency.IsHistoric.Should().BeFalse(); 18 | } 19 | 20 | [Fact] 21 | public void WhenCodeIsUnknown_ReturnFalse() 22 | { 23 | // Arrange & Act 24 | var result = CurrencyInfo.TryFromCode("AAA", out CurrencyInfo currency); 25 | 26 | // Assert 27 | result.Should().BeFalse(); 28 | currency.Should().BeNull(); 29 | } 30 | 31 | [Fact] 32 | public void WhenCodeIsNull_ReturnFalse() 33 | { 34 | // Arrange & Act 35 | var result = CurrencyInfo.TryFromCode(null!, out CurrencyInfo currency); 36 | 37 | // Assert 38 | result.Should().BeFalse(); 39 | currency.Should().BeNull(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Benchmark/MoneyParsingBenchmarks.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Attributes; 2 | using NodaMoney; 3 | 4 | namespace Benchmark; 5 | 6 | [MemoryDiagnoser] 7 | public class MoneyParsingBenchmarks 8 | { 9 | const string EurosString = "€ 765.43"; 10 | 11 | [Benchmark] 12 | public Money Implicit() 13 | { 14 | return Money.Parse(EurosString); // or € 765.43 15 | } 16 | 17 | [Benchmark] 18 | public Money ImplicitTry() 19 | { 20 | Money.TryParse(EurosString, out Money euro); // or € 765.43 21 | return euro; 22 | } 23 | 24 | [Benchmark] 25 | public Money Explicit() 26 | { 27 | return Money.Parse(EurosString, CurrencyInfo.FromCode("EUR")); // or € 765.43 28 | } 29 | 30 | [Benchmark] 31 | public Money ExplicitAsSpan() 32 | { 33 | return Money.Parse(EurosString.AsSpan(), CurrencyInfo.FromCode("EUR")); // or € 765.43 34 | } 35 | 36 | [Benchmark] 37 | public Money ExplicitTry() 38 | { 39 | Money.TryParse(EurosString, CurrencyInfo.FromCode("EUR"), out Money euro); 40 | return euro; 41 | } 42 | 43 | [Benchmark] 44 | public Money ExplicitTryAsSpan() 45 | { 46 | Money.TryParse(EurosString.AsSpan(), CurrencyInfo.FromCode("EUR"), out Money euro); 47 | return euro; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /features/proposals/10-culture-locale-sensitive-defaults-and-policies.md: -------------------------------------------------------------------------------- 1 | # Culture/Locale-Sensitive Defaults and Policies 2 | 3 | Why it matters 4 | - Locale affects symbol placement, spacing, and default currency resolution. Surfacing culture in MoneyContext improves formatting/parsing defaults and symbol disambiguation. 5 | 6 | Proposal 7 | - Allow MoneyContextOptions to carry Culture/IFormatProvider and a regional currency priority list that formatters/parsers consult by default. 8 | 9 | Possible implementation 10 | - MoneyContextOptions additions: 11 | - IFormatProvider? Culture; IReadOnlyList? RegionalCurrencyPriority. 12 | - Formatter/parser integration: 13 | - MoneyFormatOptions/MoneyParseOptions default to context Culture when unspecified. 14 | - Parsing of ambiguous symbols consults RegionalCurrencyPriority. 15 | - Documentation: 16 | - Matrix of behaviors by culture and specifiers; guidance on space policies (non-breaking, narrow, etc.). 17 | 18 | Risks / considerations 19 | - Culture changes at runtime: clarify whether MoneyContext captures a snapshot or references a mutable CultureInfo. 20 | - Ensure thread-safety when using culture-derived caches. 21 | 22 | Open questions 23 | - Should DefaultCurrency be inferred from Culture when not set? 24 | - Do we expose helpers to generate priority lists from RegionInfo? 25 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneySpec/CreateMoneyWithDifferentRounding.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.MoneySpec; 2 | 3 | public class CreateMoneyWithDifferentRounding 4 | { 5 | [Fact] 6 | public void RoundUp_When_OnlyAmount() 7 | { 8 | decimal amount = 0.525m; 9 | var defaultRounding = new Money(amount, "EUR"); 10 | var differentRounding = new Money(amount, "EUR", MidpointRounding.AwayFromZero); 11 | 12 | defaultRounding.Amount.Should().Be(0.52m); 13 | differentRounding.Amount.Should().Be(0.53m); 14 | } 15 | 16 | [Fact] 17 | public void RoundUp_When_AmountAndCode() 18 | { 19 | decimal amount = 0.525m; 20 | var defaultRounding = new Money(amount, "EUR"); 21 | var differentRounding = new Money(amount, "EUR", MidpointRounding.AwayFromZero); 22 | 23 | defaultRounding.Amount.Should().Be(0.52m); 24 | differentRounding.Amount.Should().Be(0.53m); 25 | } 26 | 27 | [Fact] 28 | public void NoRounding_When_MinorUnitIsNotApplicable() 29 | { 30 | // Arrange 31 | 32 | // Act 33 | var money = new Money(0.8m, CurrencyInfo.NoCurrency); 34 | 35 | // Assert 36 | CurrencyInfo.NoCurrency.MinorUnit.Should().Be(MinorUnit.NotApplicable); 37 | money.Amount.Should().Be(0.8m); 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /features/proposals/09-context-equality-identity-and-pooling.md: -------------------------------------------------------------------------------- 1 | # Context Equality, Identity, and Pooling 2 | 3 | Why it matters 4 | - MoneyContext dedupes by options equality and indexes contexts up to a byte-sized index. Clear guarantees and diagnostics help users reason about identity, and pooling avoids index exhaustion. 5 | 6 | Proposal 7 | - Document value equality semantics, expose lightweight diagnostics, and consider weak-reference caching or increased cap for high-churn apps. 8 | 9 | Possible implementation 10 | - Guarantees/documentation: 11 | - Value-equal MoneyContextOptions => same MoneyContextIndex. Stable across process lifetime. 12 | - Diagnostics view: MoneyContext.Diagnostics returns { Index, Name?, Options snapshot }. 13 | - Pooling/caching: 14 | - Weak-reference cache for dynamically created contexts; rehydrate or GC when unused. 15 | - Optionally raise the 128 cap if safe, or make it configurable with safeguards. 16 | - Tooling: 17 | - Provide a debug-only enumerator of active contexts for observability (no public API commitments). 18 | 19 | Risks / considerations 20 | - Thread safety of caches; avoid memory leaks. 21 | - Keeping indices stable while allowing GC of unused contexts. 22 | 23 | Open questions 24 | - What compatibility guarantees exist around index assignment across versions? 25 | - Should Names participate in identity or be metadata only? 26 | -------------------------------------------------------------------------------- /tests/Benchmark/BenchmarkDotNet.Artifacts/results/Benchmark.MoneyFormattingBenchmarks-report-github.md: -------------------------------------------------------------------------------- 1 | ``` 2 | 3 | BenchmarkDotNet v0.15.6, Windows 11 (10.0.26200.7171) 4 | AMD Ryzen 7 5800H with Radeon Graphics 3.20GHz, 1 CPU, 16 logical and 8 physical cores 5 | .NET SDK 10.0.100 6 | [Host] : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3 7 | DefaultJob : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3 8 | 9 | 10 | ``` 11 | | Method | Mean | Error | Op/s | Gen0 | Allocated | 12 | |------------------------------- |---------:|--------:|------------:|-------:|----------:| 13 | | DefaultFormat | 115.0 ns | 2.30 ns | 8,698,600.7 | 0.0458 | 384 B | 14 | | FormatWithPrecision | 188.9 ns | 1.70 ns | 5,294,420.3 | 0.0572 | 480 B | 15 | | FormatProvider | 106.0 ns | 2.13 ns | 9,432,282.7 | 0.0459 | 384 B | 16 | | FormatWithPrecisionAndProvider | 193.5 ns | 1.88 ns | 5,168,147.6 | 0.0572 | 480 B | 17 | | CompactFormat | NA | NA | NA | NA | NA | 18 | | GeneralFormat | 166.4 ns | 3.39 ns | 6,009,638.1 | 0.0842 | 704 B | 19 | | RondTripFormat | 117.0 ns | 2.36 ns | 8,543,902.3 | 0.0516 | 432 B | 20 | 21 | Benchmarks with issues: 22 | MoneyFormattingBenchmarks.CompactFormat: DefaultJob 23 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneySpec/MoneyInMalagasyAriaryWhichHasFiveSubunits.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.MoneySpec; 2 | 3 | public class MoneyInMalagasyAriaryWhichHasFiveSubunits 4 | { 5 | [Theory] 6 | [InlineData(0.01, 0.0)] 7 | [InlineData(0.09, 0.0)] 8 | [InlineData(0.10, 0.0)] 9 | [InlineData(0.15, 0.2)] 10 | [InlineData(0.22, 0.2)] 11 | [InlineData(0.29, 0.2)] 12 | [InlineData(0.30, 0.4)] 13 | [InlineData(0.33, 0.4)] 14 | [InlineData(0.38, 0.4)] 15 | [InlineData(0.40, 0.4)] 16 | [InlineData(0.41, 0.4)] 17 | [InlineData(0.45, 0.4)] 18 | [InlineData(0.46, 0.4)] 19 | [InlineData(0.50, 0.4)] 20 | [InlineData(0.54, 0.6)] 21 | [InlineData(0.57, 0.6)] 22 | [InlineData(0.60, 0.6)] 23 | [InlineData(0.68, 0.6)] 24 | [InlineData(0.70, 0.8)] 25 | [InlineData(0.74, 0.8)] 26 | [InlineData(0.77, 0.8)] 27 | [InlineData(0.80, 0.8)] 28 | [InlineData(0.83, 0.8)] 29 | [InlineData(0.85, 0.8)] 30 | [InlineData(0.86, 0.8)] 31 | [InlineData(0.90, 0.8)] 32 | [InlineData(0.91, 1.0)] 33 | [InlineData(0.95, 1.0)] 34 | [InlineData(0.99, 1.0)] 35 | public void WhenOnlyAmount_ThenItShouldRoundUp(decimal input, decimal expected) 36 | { 37 | // 1 MGA = 5 iraimbilanja 38 | var money = new Money(input, "MGA"); 39 | 40 | money.Amount.Should().Be(expected); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /features/proposals/13-precision-scale-governance-and-trailing-zero-policy.md: -------------------------------------------------------------------------------- 1 | # Precision/Scale Governance and Trailing Zero Policy 2 | 3 | Why it matters 4 | - Display scale (what users see) often differs from calculation scale. Preserving trailing zeros is important for invoices and regulatory documents. 5 | 6 | Proposal 7 | - Add display-focused options separate from calculation options: ScaleDisplayPolicy and PreserveTrailingZeros, with per-currency overrides and a quantize toggle distinct from rounding. 8 | 9 | Possible implementation 10 | - MoneyContextOptions additions: 11 | - int? DisplayMinDecimals; int? DisplayMaxDecimals; bool PreserveTrailingZeros; bool QuantizeToCurrencyMinorUnits (display-time only). 12 | - Optional per-currency override table. 13 | - Formatter integration: 14 | - MoneyFormatOptions respects these display settings when present; otherwise uses culture defaults. 15 | - Calculation vs display: 16 | - Keep MaxScale/Precision for calculations. Display settings affect ToString/formatting only. 17 | 18 | Risks / considerations 19 | - Avoid confusing users by clearly separating calculation vs display concerns. 20 | - Rounding strategy should remain authoritative for numerical results; display quantization is cosmetic. 21 | 22 | Open questions 23 | - Where to house per-currency overrides (config vs code)? 24 | - Should display policies be part of named MoneyContexts or pass via format options only? 25 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/ExchangeRateSpec/ConvertExchangeRateToString.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using NodaMoney.Exchange; 3 | using NodaMoney.Tests.Helpers; 4 | 5 | namespace NodaMoney.Tests.ExchangeRateSpec; 6 | 7 | [Collection(nameof(NoParallelization))] 8 | public class ConvertExchangeRateToString 9 | { 10 | ExchangeRate fx = new ExchangeRate(CurrencyInfo.FromCode("EUR"), CurrencyInfo.FromCode("USD"), 1.2524); 11 | 12 | [Fact, UseCulture("en-US")] 13 | public void WhenShowingExchangeRateInAmerica_ThenReturnCurrencyPairWithDot() 14 | { 15 | fx.ToString().Should().Be("EUR/USD 1.2524"); 16 | } 17 | 18 | [Fact, UseCulture("nl-NL")] 19 | public void WhenShowingExchangeRateInNetherlands_ThenReturnCurrencyPairWithComma() 20 | { 21 | fx.ToString().Should().Be("EUR/USD 1,2524"); 22 | } 23 | 24 | [Fact, UseCulture("nl-NL")] 25 | public void WhenShowingExchangeRateInNetherlands_ThenReturnCurrencyPairWithDotWhenInvariantCultureIsSpecified() 26 | { 27 | fx.ToString(CultureInfo.InvariantCulture).Should().Be("EUR/USD 1.2524"); 28 | } 29 | 30 | [Fact] 31 | public void WhenShowingExchangeRateWithSpecifiedCulture_ThenReturnCurrencyAccordingly() 32 | { 33 | fx.ToString(CultureInfo.GetCultureInfo("en-US")).Should().Be("EUR/USD 1.2524"); 34 | fx.ToString(CultureInfo.GetCultureInfo("nl-NL")).Should().Be("EUR/USD 1,2524"); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /features/proposals/16-test-kits-and-property-based-tests.md: -------------------------------------------------------------------------------- 1 | # Test Kits and Property-Based Tests 2 | 3 | Why it matters 4 | - Mature monetary libraries emphasize correctness through comprehensive tests and invariants. Shipping a test kit helps integrators validate custom providers; property tests catch edge cases. 5 | 6 | Proposal 7 | - Provide a small test kit for rounding and exchange providers and add property-based tests for arithmetic/allocations invariants. 8 | 9 | Possible implementation 10 | - Test kit: 11 | - Helpers to validate IRoundingProvider contracts (idempotence, currency-aware scale) and IExchangeRateProvider invariants (inverse consistency, triangulation checks). 12 | - Sample fixtures: InMemoryExchangeRateProvider usable in tests. 13 | - Property-based tests: 14 | - Arithmetic invariants under rounding constraints (e.g., associativity where applicable). 15 | - Allocation invariants: sum(parts) == original (except TowardZero), deterministic remainder distribution. 16 | - CI integration: 17 | - Cover multiple TFMs; collect coverage via coverlet.collector. 18 | 19 | Risks / considerations 20 | - Avoid over-constraining providers that legitimately diverge (e.g., pricing layers). 21 | - Keep tests deterministic and CI-friendly (no network). 22 | 23 | Open questions 24 | - Which property testing library to standardize on (FsCheck/QuickCheck-like for C#)? 25 | - Should test kit live under tests/ or be a separate package (NodaMoney.TestKit)? 26 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pull Request 3 | 4 | on: 5 | pull_request: 6 | branches: [ master, main ] 7 | paths-ignore: 8 | - docs/** 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | permissions: 15 | checks: write 16 | packages: write 17 | 18 | steps: 19 | - name: 📥 Checkout 20 | uses: actions/checkout@v5 21 | with: 22 | fetch-depth: 0 23 | filter: tree:0 24 | 25 | - name: 🛠️ Setup .NET 26 | uses: actions/setup-dotnet@v5 27 | with: 28 | dotnet-version: | 29 | 6.0.x 30 | 8.0.x 31 | 9.0.x 32 | 10.0.x 33 | cache: true 34 | cache-dependency-path: '**/packages.lock.json' 35 | env: 36 | NUGET_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} 37 | 38 | - name: 💾 Restore dependencies 39 | run: dotnet restore 40 | 41 | - name: ⚙️ Build 42 | run: dotnet build -c Release --no-restore 43 | 44 | - name: 🚦 Test 45 | run: | 46 | dotnet test -f net6.0 -c Release -l trx --results-directory ./artifacts/ --no-build 47 | dotnet test -f net8.0 -c Release -l trx --results-directory ./artifacts/ --no-build 48 | dotnet test -f net9.0 -c Release -l trx --results-directory ./artifacts/ --no-build 49 | dotnet test -f net10.0 -c Release -l trx --results-directory ./artifacts/ --no-build 50 | -------------------------------------------------------------------------------- /features/cldr/feature-aot-trimming.md: -------------------------------------------------------------------------------- 1 | # Feature Proposal: AOT & Trimming Considerations 2 | 3 | Status: proposal/spec 4 | Source: docs/feature-cldr-formatting-and-parsing.md 5 | 6 | ## Objectives 7 | Ensure all formatting/parsing providers—especially the CLDR subset implementation—are compatible with trimming and native AOT across the supported TFMs. 8 | 9 | ## Guidelines 10 | - Avoid reflection-based access paths; no runtime type discovery for data tables. 11 | - Prefer source-generated code tables or fixed-layout binary resources with a tiny loader using spans. 12 | - Use System.Collections.Frozen on net8+/net9+/net10 with compile-time directives and safe fallbacks. 13 | - Avoid culture lookup via reflection; normalize culture names and use static parent maps. 14 | - Keep public surface small to minimize trimming roots. 15 | 16 | ## Possible Implementations 17 | - Codegen data (preferred): readonly arrays + FrozenDictionary indexes; no IO required. 18 | - Binary data: ReadOnlyMemory with BinaryPrimitives; only allocate strings when needed. 19 | - Multi-TFM conditionals for FrozenDictionary; ImmutableDictionary on older TFMs. 20 | 21 | ## Diagnostics 22 | - Expose CldrInfo.Version and ProviderInfo.Name for debugging; ensure they are const/readonly. 23 | 24 | ## Acceptance Criteria 25 | - Builds with PublishTrimmed=true and PublishAot=true in test projects. 26 | - No linker warnings in default configuration. 27 | - Deterministic behavior verified in AOT runs on Windows/Linux. 28 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/CurrencySpec/DefaultCurrency.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.CurrencySpec; 2 | 3 | public class DefaultCurrency 4 | { 5 | [Fact] 6 | public void WhenCreatingVariableWithDefault_ThenShouldBeEqualToNoCurrency() 7 | { 8 | // Arrange / Act 9 | Currency currency = default; 10 | 11 | // Assert 12 | var expected = Currency.FromCode("XXX"); 13 | 14 | currency.Should().NotBeNull(); 15 | currency.Should().Be(expected); 16 | currency.Code.Should().Be(expected.Code); 17 | currency.Symbol.Should().Be(expected.Symbol); 18 | } 19 | 20 | [Fact] 21 | public void WhenNoCurrency_ThenItShouldBeEqualToDefault() 22 | { 23 | // Arrange / Act 24 | Currency noCurrency = Currency.FromCode("XXX"); 25 | 26 | // Assert 27 | noCurrency.Should().NotBeNull(); 28 | noCurrency.Should().Be(default(Currency)); 29 | 30 | // Assert with XUnit methods, because https://stackoverflow.com/questions/61556309/fluent-assertions-be-vs-equals 31 | Assert.Equal(default, noCurrency); 32 | Assert.Equal(default(Currency), (object)noCurrency); 33 | Assert.True(noCurrency == default); 34 | Assert.True(noCurrency == default(Currency)); 35 | Assert.True(noCurrency.Equals(default)); 36 | Assert.True(noCurrency.Equals((object)default(Currency))); 37 | Assert.True(object.Equals(noCurrency, default(Currency))); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /features/proposals/05-big-unscaled-amount-type.md: -------------------------------------------------------------------------------- 1 | # Big/Unscaled Amount Type (BigMoney analog) 2 | 3 | Why it matters 4 | - Arbitrary-scale intermediates are useful for precise calculations where currency minor units should not be enforced until finalization (Joda-Money BigMoney pattern). 5 | 6 | Proposal 7 | - Introduce a BigMoney-like type (name TBD) that does not enforce currency scale during arithmetic. Integrate with MoneyContext for finalization/quantization when converting back to Money. 8 | 9 | Possible implementation 10 | - Type design: 11 | - Immutable struct/class holding decimal (or BigInteger + scale if needed) and CurrencyInfo. 12 | - Methods for arithmetic that preserve full precision; defer rounding. 13 | - ToMoney(MoneyContext?) applies context rounding/scale. 14 | - Interop rules: 15 | - Explicit cast Money -> Big-like preserves numeric value; Big-like -> Money applies rounding. 16 | - Operators between Money and Big-like promote to Big-like, then allow explicit finalization. 17 | - Context integration: 18 | - MoneyContext exposes preferred calculation scale/precision for Big-like operations. 19 | 20 | Risks / considerations 21 | - Performance and memory if using high precision. Start with decimal until proven insufficient. 22 | - Clear documentation of when rounding happens to avoid surprises. 23 | 24 | Open questions 25 | - Do we add analyzers to flag implicit lossy conversions? 26 | - Should Big-like be limited to internal calculations or fully public API? 27 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneySpec/MoneyImplicit.cs: -------------------------------------------------------------------------------- 1 | using NodaMoney.Tests.Helpers; 2 | 3 | namespace NodaMoney.Tests.MoneySpec; 4 | 5 | [Collection(nameof(NoParallelization))] 6 | public class MoneyImplicit 7 | { 8 | private readonly decimal _decimalValue = 1234.567m; 9 | 10 | [Fact] 11 | [UseCulture("en-US")] 12 | public void WhenCurrentCultureIsUS_ThenCurrencyIsDollar() 13 | { 14 | var money = new Money(_decimalValue); 15 | 16 | money.Currency.Should().Be(Currency.FromCode("USD")); 17 | money.Amount.Should().Be(1234.57m); 18 | } 19 | 20 | [Fact] 21 | [UseCulture("nl-NL")] 22 | public void WhenCurrentCultureIsNL_ThenCurrencyIsEuro() 23 | { 24 | var money = new Money(_decimalValue); 25 | 26 | money.Currency.Should().Be(Currency.FromCode("EUR")); 27 | money.Amount.Should().Be(1234.57m); 28 | } 29 | 30 | [Fact] 31 | [UseCulture("ja-JP")] 32 | public void WhenCurrentCultureIsJP_ThenCurrencyIsYen() 33 | { 34 | var money = new Money(_decimalValue); 35 | 36 | money.Currency.Should().Be(Currency.FromCode("JPY")); 37 | money.Amount.Should().Be(1235m); 38 | } 39 | 40 | [Fact] 41 | [UseCulture(null)] 42 | public void WhenCurrentCultureIsInvariant_ThenCurrencyIsDefault() 43 | { 44 | var money = new Money(_decimalValue); 45 | 46 | money.Currency.Should().Be(default(Currency)); 47 | money.Amount.Should().Be(1234.567m, because: "no rounding"); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /features/proposals/14-deterministic-arithmetic-across-types.md: -------------------------------------------------------------------------------- 1 | # Deterministic Arithmetic Across Types (Money, FastMoney, Big-like) 2 | 3 | Why it matters 4 | - Mixing amount types can lead to surprising results if promotion/demotion and rounding are not clearly defined. 5 | 6 | Proposal 7 | - Document and enforce cross-type operation rules: promotion order, rounding points, and context-driven behavior. Add analyzers to guard unsafe implicit conversions. 8 | 9 | Possible implementation 10 | - Rules: 11 | - Promotion order: Money x FastMoney => FastMoney (or Big-like if introduced). Money/FastMoney x Big-like => Big-like. 12 | - Rounding: apply MoneyContext at finalization (e.g., when creating Money from promoted result) not at every step. 13 | - Currency checks: operations require same currency unless explicitly converted; zero-amount policy may relax. 14 | - API/Operators: 15 | - Define explicit/implicit casts where safe. Provide TryDemote for lossy conversions. 16 | - Analyzers: 17 | - Flag implicit conversions that lose precision; suggest explicit ToMoney(context) or With(context). 18 | - Tests/Docs: 19 | - Add spec documenting numeric semantics akin to .NET numeric types, plus examples in docs/README.md. 20 | 21 | Risks / considerations 22 | - Backward compatibility if existing behavior differs; consider opt-in via analyzer warnings first. 23 | 24 | Open questions 25 | - Should promotion prefer precision (Big-like) or performance (FastMoney) by default? 26 | - Provide configuration in MoneyContext to pick a policy? 27 | -------------------------------------------------------------------------------- /src/NodaMoney/Serialization/CurrencyJsonConverter.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace NodaMoney.Serialization; 5 | 6 | /// Converts a Currency type to or from JSON. 7 | /// Used by System.Text.Json to do the (de)serialization. 8 | #pragma warning disable CA1704 9 | public class CurrencyJsonConverter : JsonConverter 10 | #pragma warning restore CA1704 11 | { 12 | /// 13 | public override Currency Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 14 | { 15 | if (reader.TokenType != JsonTokenType.String) 16 | throw new JsonException("Currency code is not a string! Expected a string like 'EUR' or 'USD'."); 17 | 18 | var code = reader.GetString(); 19 | if (string.IsNullOrWhiteSpace(code)) 20 | { 21 | throw new JsonException("Currency code is null or empty! Expected a string like 'EUR' or 'USD'."); 22 | } 23 | 24 | try 25 | { 26 | return CurrencyInfo.FromCode(code); 27 | } 28 | catch (Exception ex) when (ex is ArgumentException or KeyNotFoundException) 29 | { 30 | throw new JsonException($"Currency code '{code}' is not a known currency!", ex); 31 | } 32 | } 33 | 34 | /// 35 | public override void Write(Utf8JsonWriter writer, Currency value, JsonSerializerOptions options) => 36 | writer.WriteStringValue(value.Code); 37 | } 38 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneyRoundingSpec/ApplyStandardRounding.cs: -------------------------------------------------------------------------------- 1 | using NodaMoney.Context; 2 | 3 | namespace NodaMoney.Tests.MoneyRoundingSpec; 4 | 5 | public class ApplyStandardRounding 6 | { 7 | [Fact] 8 | public void WhenRoundingDecimalBasedCurrency_ShouldRoundCorrectly() 9 | { 10 | // Arrange 11 | var strategy = new StandardRounding(); 12 | var currencyInfo = CurrencyInfo.FromCode("EUR"); 13 | 14 | // Act 15 | var result = strategy.Round(10.235m, currencyInfo, null); 16 | 17 | // Assert 18 | result.Should().Be(10.24m); 19 | } 20 | 21 | [Fact] 22 | public void WhenRoundingNonDecimalBasedCurrency_ShouldRoundCorrectly() 23 | { 24 | // Arrange 25 | var strategy = new StandardRounding(); 26 | var currencyInfo = CurrencyInfo.FromCode("MRU"); // 1/5 27 | 28 | // Act 29 | var result = strategy.Round(10.22m, currencyInfo, null); 30 | 31 | // Assert 32 | result.Should().Be(10.2m); 33 | } 34 | 35 | [Theory] 36 | [InlineData(-1)] 37 | [InlineData(29)] 38 | public void WhenDecimalsOutOfRange_ShouldThrowArgumentOutOfRangeException(int decimals) 39 | { 40 | // Arrange 41 | var strategy = new StandardRounding(); 42 | var currencyInfo = CurrencyInfo.FromCode("EUR"); 43 | 44 | // Act 45 | var act = () => strategy.Round(10.235m, currencyInfo, decimals); 46 | 47 | // Assert 48 | act.Should().Throw(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/CurrencyInfoSpec/CurrencyInfoFromCode.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.CurrencyInfoSpec; 2 | 3 | public class CurrencyInfoFromCode 4 | { 5 | [Fact] 6 | public void WhenCodeExists_ReturnCurrencyInfo() 7 | { 8 | // Arrange & Act 9 | var currency = CurrencyInfo.FromCode("EUR"); 10 | 11 | // Assert 12 | currency.Should().NotBeNull(); 13 | currency.Symbol.Should().Be("€"); 14 | currency.Code.Should().Be("EUR"); 15 | currency.EnglishName.Should().Be("Euro"); 16 | currency.IsHistoric.Should().BeFalse(); 17 | } 18 | 19 | [Fact] 20 | public void WhenCodeIsUnknown_ThrowInvalidCurrencyException() 21 | { 22 | // Arrange & Act 23 | Action action = () => CurrencyInfo.FromCode("AAA"); 24 | 25 | // Assert 26 | action.Should().Throw(); 27 | } 28 | 29 | [Fact] 30 | public void WhenCodeIsNull_ThrowInvalidCurrencyException() 31 | { 32 | // Arrange & Act 33 | Action action = () => CurrencyInfo.FromCode(null); 34 | 35 | // Assert 36 | action.Should().Throw(); 37 | } 38 | 39 | [Fact] 40 | public void WhenEstionianKrone_ReturnObsoleteCurrencyInfo() 41 | { 42 | // Arrange & Act 43 | var currency = CurrencyInfo.FromCode("EEK"); 44 | 45 | // Assert 46 | currency.Should().NotBeNull(); 47 | currency.Symbol.Should().Be("kr"); 48 | currency.IsHistoric.Should().BeTrue(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/FastMoneySpec/MoneyImplicit.cs: -------------------------------------------------------------------------------- 1 | using NodaMoney.Tests.Helpers; 2 | 3 | namespace NodaMoney.Tests.FastMoneySpec; 4 | 5 | [Collection(nameof(NoParallelization))] 6 | public class MoneyImplicit 7 | { 8 | private readonly decimal _decimalValue = 1234.56789m; 9 | 10 | [Fact] 11 | [UseCulture("en-US")] 12 | public void WhenCurrentCultureIsUS_ThenCurrencyIsDollar() 13 | { 14 | var money = new FastMoney(_decimalValue); 15 | 16 | money.Currency.Should().Be(Currency.FromCode("USD")); 17 | money.Amount.Should().Be(1234.5679m); 18 | } 19 | 20 | [Fact] 21 | [UseCulture("nl-NL")] 22 | public void WhenCurrentCultureIsNL_ThenCurrencyIsEuro() 23 | { 24 | var money = new FastMoney(_decimalValue); 25 | 26 | money.Currency.Should().Be(Currency.FromCode("EUR")); 27 | money.Amount.Should().Be(1234.5679m); 28 | } 29 | 30 | [Fact] 31 | [UseCulture("ja-JP")] 32 | public void WhenCurrentCultureIsJP_ThenCurrencyIsYen() 33 | { 34 | var money = new FastMoney(_decimalValue); 35 | 36 | money.Currency.Should().Be(Currency.FromCode("JPY")); 37 | money.Amount.Should().Be(1234.5679m); 38 | } 39 | 40 | [Fact] 41 | [UseCulture(null)] 42 | public void WhenCurrentCultureIsInvariant_ThenCurrencyIsDefault() 43 | { 44 | var money = new FastMoney(_decimalValue); 45 | 46 | money.Currency.Should().Be(default(Currency)); 47 | money.Amount.Should().Be(1234.5679m, because: "no rounding"); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneyConvertibleSpec/ExplicitCastMoneyToNumericType.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.MoneyConvertibleSpec; 2 | 3 | public class ExplicitCastMoneyToNumericType 4 | { 5 | readonly Money _euros = new Money(10.00m, "EUR"); 6 | 7 | [Fact] 8 | public void WhenExplicitCastingToDecimal_ThenCastingShouldSucceed() 9 | { 10 | var m = (decimal)_euros; 11 | 12 | m.Should().Be(10.00m); 13 | } 14 | 15 | [Fact] 16 | public void WhenExplicitCastingToDouble_ThenCastingShouldSucceed() 17 | { 18 | var d = (double)_euros; 19 | 20 | d.Should().Be(10.00d); 21 | } 22 | 23 | [Fact] 24 | public void WhenExplicitCastingToFloat_ThenCastingShouldSucceed() 25 | { 26 | var f = (float)_euros; 27 | 28 | f.Should().Be(10.00f); 29 | } 30 | 31 | [Fact] 32 | public void WhenExplicitCastingToLong_ThenCastingShouldSucceed() 33 | { 34 | var l = (long)_euros; 35 | 36 | l.Should().Be(10L); 37 | } 38 | 39 | [Fact] 40 | public void WhenExplicitCastingToByte_ThenCastingShouldSucceed() 41 | { 42 | var b = (byte)_euros; 43 | 44 | b.Should().Be(10); 45 | } 46 | 47 | [Fact] 48 | public void WhenExplicitCastingToShort_ThenCastingShouldSucceed() 49 | { 50 | var s = (short)_euros; 51 | 52 | s.Should().Be(10); 53 | } 54 | 55 | [Fact] 56 | public void WhenExplicitCastingToInt_ThenCastingShouldSucceed() 57 | { 58 | var i = (int)_euros; 59 | 60 | i.Should().Be(10); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/CurrencySpec/CompareCurrencies.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.CurrencySpec; 2 | 3 | public class CompareCurrencies 4 | { 5 | private Currency _euro1 = CurrencyInfo.FromCode("EUR"); 6 | 7 | private Currency _euro2 = CurrencyInfo.FromCode("EUR"); 8 | 9 | private Currency _dollar = CurrencyInfo.FromCode("USD"); 10 | 11 | [Fact] 12 | public void WhenComparingEquality_ThenCurrencyShouldBeEqual() 13 | { 14 | // Compare using Equal() 15 | _euro1.Should().Be(_euro2); 16 | _euro1.Should().NotBe(_dollar); 17 | _euro1.Should().NotBeNull(); 18 | _euro1.Should().NotBe(new object(), "comparing Currency to a different object should fail!"); 19 | } 20 | 21 | [Fact] 22 | public void WhenComparingStaticEquality_ThenCurrencyShouldBeEqual() 23 | { 24 | // Compare using static Equal() 25 | Currency.Equals(_euro1, _euro2).Should().BeTrue(); 26 | Currency.Equals(_euro1, _dollar).Should().BeFalse(); 27 | } 28 | 29 | [Fact] 30 | public void WhenComparingWithEqualityOperator_ThenCurrencyShouldBeEqual() 31 | { 32 | // Compare using Euality operators 33 | (_euro1 == _euro2).Should().BeTrue(); 34 | (_euro1 != _dollar).Should().BeTrue(); 35 | } 36 | 37 | [Fact] 38 | public void WhenComparingHashCodes_ThenCurrencyShouldBeEqual() 39 | { 40 | // Compare using GetHashCode() 41 | _euro1.GetHashCode().Should().Be(_euro2.GetHashCode()); 42 | _euro1.GetHashCode().Should().NotBe(_dollar.GetHashCode()); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /features/proposals/12-serialization-ecosystem-completeness.md: -------------------------------------------------------------------------------- 1 | # Serialization Ecosystem Completeness 2 | 3 | Why it matters 4 | - Interop needs across serializers (System.Text.Json, Newtonsoft.Json, BSON/Mongo) and AOT readiness are common. Stable, lossless wire formats reduce ambiguity. 5 | 6 | Proposal 7 | - Provide parity converters (STJ, Newtonsoft, BSON), source-gen support, and a stable canonical JSON schema that is lossless and optionally includes context metadata. 8 | 9 | Possible implementation 10 | - Converters: 11 | - System.Text.Json: ensure converters for Money and CurrencyInfo; add source-generated JsonSerializerContext examples. 12 | - Newtonsoft.Json: parity converters mirroring STJ behavior. 13 | - Mongo/BSON: Money serializer with string amount to avoid precision loss. 14 | - Canonical schema option: 15 | - { "currencyCode": "USD", "amount": "1234.56", "contextName": "default"? } 16 | - Allow opt-in via options for string amounts (lossless) vs numeric for convenience. 17 | - AOT-friendliness: 18 | - Provide sample JsonSerializerContext and linker descriptors if needed. 19 | - Tests: 20 | - Round-trip tests across serializers and TFMs; ensure culture-invariant behavior. 21 | 22 | Risks / considerations 23 | - Backward compatibility with existing serialized forms; consider versioned schema. 24 | - Precision loss if numeric amounts are used; recommend string for canonical wire format. 25 | 26 | Open questions 27 | - Should contextName be emitted/consumed by default or only when requested? 28 | - Do we include currency minor units or metadata for display scale? 29 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/Serialization/InvalidJsonV1TestData.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.Serialization; 2 | 3 | public class InvalidJsonV1TestData : TheoryData 4 | { 5 | public InvalidJsonV1TestData() 6 | { 7 | Add("""{ "Amount": 234.25 }"""); // PascalCase, Amount as number, No Currency member 8 | Add("""{ "Currency": "EUR" }"""); // PascalCase, No Amount member 9 | Add("""{ "Amount": "234.25" }"""); // PascalCase, Amount as string, No Currency member 10 | Add("""{ "amount": 234.25 }"""); // camelCase, Amount as number, No Currency member 11 | Add("""{ "currency": "EUR" }"""); // camelCase, No Amount member 12 | 13 | // Members no quotation marks 14 | Add("""{ Amount: 234.25 }"""); // PascalCase, Amount as number, No Currency member 15 | Add("""{ Currency: "EUR" }"""); // PascalCase, No Amount member 16 | Add("""{ Amount: "234.25" }"""); // PascalCase, Amount as string, No Currency member 17 | Add("""{ amount: 234.25 }"""); // camelCase, Amount as number, No Currency member 18 | Add("""{ currency: "EUR" }"""); // camelCase, No Amount member 19 | 20 | // Members no quotation marks, Values single quotes 21 | Add("""{ Currency: 'EUR' }"""); // PascalCase, No Amount member, 22 | Add("""{ Amount: '234.25' }"""); // PascalCase, Amount as string, No Currency member 23 | Add("""{ currency: 'EUR' }"""); // camelCase, No Amount member 24 | 25 | Add("""{ "Amount": "ABC", "Currency": "EUR" }"""); // => format exception without telling which member 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /features/cldr/feature-examples.md: -------------------------------------------------------------------------------- 1 | # Feature Proposal: Examples & Usage Snippets 2 | 3 | Status: companion to proposals 4 | Source: docs/feature-cldr-formatting-and-parsing.md 5 | 6 | ## DI Registration 7 | ```csharp 8 | // Default to CLDR provider when the package is referenced 9 | services.AddMoneyFormatting().UseCldr(); 10 | 11 | // Or explicitly use Culture fallback 12 | services.AddMoneyFormatting().UseCulture(); 13 | ``` 14 | 15 | ## Formatting 16 | ```csharp 17 | var options = new MoneyFormatOptions 18 | { 19 | Culture = CultureInfo.GetCultureInfo("nl-NL"), 20 | CurrencyDisplay = CurrencyDisplay.Symbol, 21 | SignDisplay = SignDisplay.Accounting, 22 | UseCashDigits = true 23 | }; 24 | 25 | IMoneyFormatter formatter = services.GetRequiredService(); 26 | var text = formatter.Format(new Money(1234.5m, Currency.EUR), options); // "€\u00A01.234,50" 27 | ``` 28 | 29 | ## Parsing 30 | ```csharp 31 | IMoneyParser parser = services.GetRequiredService(); 32 | if (parser.TryParse("$\u00A0(1,234.50)", CultureInfo.GetCultureInfo("en-US"), out var amount, 33 | new MoneyFormatOptions { SignDisplay = SignDisplay.Accounting })) 34 | { 35 | // amount == -1234.50 USD 36 | } 37 | ``` 38 | 39 | ## Scoped override via MoneyContext 40 | ```csharp 41 | using var scope = MoneyContext.CreateScope(o => o.FormattingProvider = FormattingProvider.Cldr); 42 | ``` 43 | 44 | ## Notes 45 | - UseCashDigits consults CLDR digits and can integrate with cash rounding strategies in MoneyContext. 46 | - Narrow symbols fall back to standard symbol when CLDR narrow is missing. 47 | -------------------------------------------------------------------------------- /features/proposals/02-cash-rounding-and-denomination-policies.md: -------------------------------------------------------------------------------- 1 | # Cash Rounding and Denomination Policies per Currency/Region 2 | 3 | Why it matters 4 | - Cash transactions often require denomination rounding (e.g., CHF 0.05). Policy can differ from electronic rounding and may change over time per region. 5 | 6 | Proposal 7 | - Extend existing CashDenominationRounding with data-backed defaults per currency/region and effective dates. Allow toggling via MoneyContext (IsCashTransaction) and/or via RoundingQuery attribute. 8 | 9 | Possible implementation 10 | - Data layer: 11 | - Registry mapping (Currency/Region, EffectiveFrom..To) -> Increment (e.g., CHF: 0.05). 12 | - Provide JSON or generated source data; keep AOT-friendly. 13 | - Strategy/provider: 14 | - Implement CashDenominationRounding using the registry; expose as IRoundingStrategy. 15 | - An IRoundingProvider that chooses cash vs electronic rounding based on query.IsCash. 16 | - MoneyContext options: 17 | - bool IsCashTransaction; override per scope/operation. 18 | - Allow custom per-context override table. 19 | - Serialization/formatting tie-in: 20 | - Formatter option CashDisplay: true uses cash rounding for display without changing stored value. 21 | 22 | Risks / considerations 23 | - Keep data current and regionalized; support date-effective changes. 24 | - Ensure conversions and splits use the correct policy when IsCashTransaction is set. 25 | 26 | Open questions 27 | - Do we model region at CultureInfo or explicit region code level? 28 | - How to ship/maintain default denomination data (embedded vs external package)? 29 | -------------------------------------------------------------------------------- /features/cldr/feature-migration-versioning.md: -------------------------------------------------------------------------------- 1 | # Feature Proposal: Migration & Versioning 2 | 3 | Status: proposal/spec 4 | Source: docs/feature-cldr-formatting-and-parsing.md 5 | 6 | ## Objectives 7 | Introduce the CLDR-aware formatting/parsing capabilities as a non-breaking enhancement, clearly versioned and easy to adopt. 8 | 9 | ## Release Strategy 10 | - Add new APIs (interfaces, options) in a minor release of NodaMoney core. 11 | - Ship NodaMoney.Globalization.Cldr as a separate package with generated data. 12 | - Optionally ship NodaMoney.Globalization.Icu4n adapter package. 13 | 14 | ## Version Pinning 15 | - Pin CLDR dataset version (e.g., 46) in the generated code and expose CldrInfo.Version. 16 | - Document update cadence (e.g., quarterly or on-demand) and changelog entries. 17 | 18 | ## Compatibility 19 | - Money.ToString() remains unchanged; new behavior is opt-in via IMoneyFormatter/IMoneyParser. 20 | - DI defaults: prefer CLDR provider when package referenced; otherwise Culture fallback. 21 | - Parsing and formatting differences vs legacy path are documented with examples. 22 | 23 | ## Migration Guide (Outline) 24 | - How to reference the CLDR package and enable it via DI. 25 | - How to call the formatter/parser explicitly. 26 | - How to set UseCashDigits and understand effects on rounding and fraction digits. 27 | - Known differences vs CultureInfo-based formatting. 28 | 29 | ## Acceptance Criteria 30 | - Clear docs in docs/README.md referencing the new features and packages. 31 | - SemVer respected; no breaking changes to public APIs. 32 | - CLDR version surfaced programmatically for diagnostics. 33 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/Serialization/NestedJsonV1TestData.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.Serialization; 2 | 3 | public class NestedJsonV1TestData : TheoryData 4 | { 5 | public NestedJsonV1TestData() 6 | { 7 | var order = new Order 8 | { 9 | Id = 123, 10 | Name = "Abc", 11 | Total = new Money(234.25m, Currency.FromCode("EUR")) 12 | }; 13 | 14 | Add("""{ "Id": 123, "Name": "Abc", "Total": { "Amount": 234.25, "Currency": "EUR" } }""", order); // Amount as number 15 | Add("""{ "Id": 123, "Name": "Abc", "Total": { "Amount": "234.25", "Currency": "EUR" } }""", order); // Amount as string 16 | 17 | // Reversed members 18 | Add("""{ "Id": 123, "Name": "Abc", "Total": { "Currency": "EUR", "Amount": 234.25 } }""", order); // Amount as number 19 | Add("""{ "Id": 123, "Name": "Abc", "Total": { "Currency": "EUR", "Amount": "234.25" } }""", order); // Amount as string 20 | 21 | // camelCase 22 | Add("""{ "id": 123, "name": "Abc", "total": { "amount": 234.25, "currency": "EUR" } }""", order); // Amount as number 23 | Add("""{ "id": 123, "name": "Abc", "total": { "amount": "234.25", "currency": "EUR" } }""", order); // Amount as string 24 | 25 | // Discount explicit null 26 | Add("""{ "Id": 123, "Name": "Abc", "Total": { "Amount": 234.25, "Currency": "EUR" }, "Discount": null }""", order); // Amount as number 27 | Add("""{ "Id": 123, "Name": "Abc", "Total": { "Amount": "234.25", "Currency": "EUR" }, "Discount": null }""", order); // Amount as string 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /features/proposals/07-monetary-operators-and-queries.md: -------------------------------------------------------------------------------- 1 | # Monetary Operators and Queries (Composable Pipelines) 2 | 3 | Why it matters 4 | - JavaMoney idioms (MonetaryOperator/MonetaryQuery) enable reusable, DI-discoverable transformations (e.g., tax, discount) and queries with metadata. 5 | 6 | Proposal 7 | - Support both delegate-based and interface-based operators/queries for ergonomics and extensibility. Provide a small standard library and a Money.With(...) helper. 8 | 9 | Possible implementation 10 | - Interfaces: 11 | - interface IMoneyOperator { Money Apply(in Money value, MoneyContext? context = null); } 12 | - interface IMoneyQuery { T Evaluate(in Money value, MoneyContext? context = null); } 13 | - Extension helpers: 14 | - Money With(Func op) => op(value) 15 | - Money With(IMoneyOperator op, MoneyContext? ctx = null) => op.Apply(value, ctx) 16 | - T Query(Func query) / T Query(IMoneyQuery query, MoneyContext? ctx = null) 17 | - Built-ins: 18 | - readonly struct PercentageOperator : IMoneyOperator { decimal Percent; Apply => value * (1 + Percent/100m); } 19 | - TaxOperator, DiscountOperator, CapFloorOperator 20 | - DI integration: 21 | - Register named IMoneyOperator implementations in NodaMoney.DependencyInjection. 22 | 23 | Risks / considerations 24 | - Avoid allocations by using readonly structs for built-ins. 25 | - Keep semantics consistent with Money operators and MoneyContext rounding. 26 | 27 | Open questions 28 | - Do we need metadata (id/version) on operators for audit? 29 | - Should context be mandatory for certain operators (e.g., cash-specific)? 30 | -------------------------------------------------------------------------------- /features/proposals/06-allocation-proration-apis.md: -------------------------------------------------------------------------------- 1 | # Allocation / Proration APIs (Split) 2 | 3 | Why it matters 4 | - Deterministic allocation of amounts into equal or ratio-based parts while preserving totals is a common need (Joda-Money allocate/allocateByRatios). 5 | 6 | Proposal 7 | - Provide first-class Split APIs as instance/extension methods on Money and FastMoney with policy-driven remainder handling and MoneyContext-aware rounding. 8 | 9 | Possible implementation 10 | - Methods: 11 | - Money[] Split(int parts, SplitRemainderPolicy policy = LargestRemainder, MoneyContext? context = null) 12 | - Money[] Split(SplitRemainderPolicy policy, params int[] ratios) 13 | - Money[] SplitByWeights(ReadOnlySpan weights, SplitRemainderPolicy policy = LargestRemainder, MoneyContext? context = null) 14 | - Money[] SplitExact(ReadOnlySpan partsTemplate) 15 | - Policies: 16 | - LargestRemainder (default), RoundRobin, TowardZero, AwayFromZero. 17 | - Algorithm: 18 | - Quantize original amount using context scale, convert to smallest units (long), distribute base units + remainders deterministically. 19 | - Integration: 20 | - Works for negative amounts; uses MoneyContext.RoundingStrategy. 21 | 22 | API sketches and tests 23 | - See features/proposals/split-design.md for detailed algorithms and unit test examples. 24 | 25 | Risks / considerations 26 | - Overflow if parts/ratios extremely large; add guards. 27 | - Document TowardZero behavior (sum(parts) != original). 28 | 29 | Open questions 30 | - Should zero-ratio indices be excluded from remainder distribution? 31 | - Provide span-based overloads to reduce allocations? 32 | -------------------------------------------------------------------------------- /features/cldr/feature-testing-strategy.md: -------------------------------------------------------------------------------- 1 | # Feature Proposal: Testing Strategy for CLDR Formatting/Parsing 2 | 3 | Status: proposal/spec 4 | Source: docs/feature-cldr-formatting-and-parsing.md 5 | 6 | ## Objectives 7 | Validate correctness, determinism, and performance of the formatting/parsing providers across locales, currencies, and platforms. 8 | 9 | ## Test Types 10 | - Golden output tests (formatter): compare outputs against ICU (ICU4N in test scope or pre-recorded fixtures) for a matrix of locales and currencies. 11 | - Round-trip tests: format with CLDR provider, parse back, assert equality (amount and currency). 12 | - Determinism tests: run on Windows/Linux/macOS to ensure identical outputs. 13 | - Cash vs standard digits: verify CHF (0.05 cash), JPY (0), SEK cases with accounting pattern. 14 | - Parsing robustness: handle NBSP/narrow NBSP, symbol vs ISO code, parentheses, and ambiguous symbols resolved by locale. 15 | - Performance/allocations: microbenchmarks to track regression (BenchmarkDotNet; not required in CI but useful locally). 16 | 17 | ## Test Layout 18 | - tests/NodaMoney.Tests/Formatting/* — unit/integration tests for public surface. 19 | - tests/NodaMoney.Globalization.Cldr.Tests/* — provider-specific golden comparisons. 20 | 21 | ## Data for Golden Tests 22 | - Record expected ICU outputs for specific CLDR version; store as fixtures pinned to the same version. 23 | - Update fixtures only when CLDR version changes; review diffs. 24 | 25 | ## Acceptance Criteria 26 | - All tests pass across supported TFMs. 27 | - Coverage includes formatting and parsing edge cases listed in proposals. 28 | - Determinism confirmed across OSes in CI matrix or via conditional runs. 29 | -------------------------------------------------------------------------------- /features/proposals/08-provider-based-discovery-and-di-integration.md: -------------------------------------------------------------------------------- 1 | # Provider-Based Discovery and DI Integration 2 | 3 | Why it matters 4 | - In .NET, DI is the idiomatic alternative to Java SPI. Registering rounding providers, exchange providers, formatters, and operators enables modular, testable setups. 5 | 6 | Proposal 7 | - Extend NodaMoney.DependencyInjection to register named MoneyContext instances and provider chains (rounding, FX, formatters, operators) with options binding and AOT-friendly configuration. 8 | 9 | Possible implementation 10 | - Registration extensions: 11 | - IServiceCollection AddMoneyContext(name, Action) with options binding from IConfiguration. 12 | - IServiceCollection AddRoundingProvider(name, IRoundingProvider) and AddCompositeRoundingProvider(...) 13 | - IServiceCollection AddExchangeRates(Func) with composite/cached decorators. 14 | - IServiceCollection AddMoneyOperator(string name, IMoneyOperator) 15 | - Named registrations resolved via keyed services or IOptionsMonitor keyed by name. 16 | - Configuration binding: 17 | - Use Options pattern to bind MoneyContextOptions (precision, scale, default currency, flags) per name. 18 | - Allow provider chains to be described in config (ids, priorities), while actual implementations are registered in code. 19 | 20 | Risks / considerations 21 | - Avoid service locator anti-pattern; expose typed facades where possible (e.g., ICurrencyConverter). 22 | - Keep AOT-friendly (no reflection-based discovery by default). 23 | 24 | Open questions 25 | - Do we want a default singleton registry for providers outside DI usage? 26 | - How to scope provider lifetimes when MoneyContext scopes are created transiently? 27 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/Serialization/RavenDbSerializationSpec/StoreInRavenDb.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using Raven.TestDriver; 3 | 4 | namespace NodaMoney.Tests.Serialization.RavenDbSerializationSpec; 5 | 6 | public class StoreInRavenDb : RavenTestDriver 7 | { 8 | static StoreInRavenDb() 9 | { 10 | // ConfigureServer() must be set before calling GetDocumentStore() and can only be set once per test run. 11 | ConfigureServer(new TestServerOptions { Licensing = { ThrowOnInvalidOrMissingLicense = false } }); 12 | } 13 | 14 | [Fact] 15 | public void WhenObjectWithMoneyAttribute_ThenThisMustWork() 16 | { 17 | // Only run this test for .NET 9.0, because only .NET 9.0 is installed on the build server. 18 | if (!AppContext.TargetFrameworkName.Contains(".NETCoreApp,Version=v9.0")) 19 | { 20 | return; // Skip execution for other frameworks 21 | } 22 | 23 | SampleData sample = new SampleData { Name = "Test", Price = new Money(123.56, "EUR"), BaseCurrency = Currency.FromCode("USD") }; 24 | 25 | using var store = GetDocumentStore(); 26 | 27 | // Store in RavenDb 28 | using (var session = store.OpenSession()) 29 | { 30 | session.Store(sample); 31 | session.SaveChanges(); 32 | } 33 | 34 | WaitForIndexing(store); 35 | //WaitForUserToContinueTheTest(store); // Sometimes we want to debug the test itself, this redirect us to the studio 36 | 37 | // Read from RavenDb 38 | using (var session = store.OpenSession()) 39 | { 40 | var result = session.Query().FirstOrDefault(); 41 | 42 | result.Name.Should().Be(sample.Name); 43 | result.Price.Should().Be(sample.Price); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /features/cldr/feature-di-and-configuration.md: -------------------------------------------------------------------------------- 1 | # Feature Proposal: DI & Configuration for Formatting/Parsing Providers 2 | 3 | Status: proposal/spec 4 | Source: docs/feature-cldr-formatting-and-parsing.md 5 | 6 | ## Objective 7 | Provide a simple and explicit way to register and select the active formatting/parsing provider via Microsoft.Extensions.DependencyInjection and MoneyContext options, supporting named contexts and per-scope overrides. 8 | 9 | ## DI Surface (Possible Implementation) 10 | - services.AddMoneyFormatting() — registers IMoneyFormatter, IMoneyParser, and IMoneyLocalePatternProvider (default flavor). 11 | - Fluent selection: 12 | - .UseCldr() — registers CldrMoneyLocalePatternProvider (if package referenced) as default. 13 | - .UseCulture() — registers CultureMoneyLocalePatternProvider. 14 | - .UseIcu4n() — (optional package) registers ICU provider. 15 | - Named contexts (optional extension): services.AddMoneyFormatting("Sales").UseCldr(); 16 | 17 | ## MoneyContext Integration 18 | - MoneyContextOptions.FormattingProvider: enum { Cldr, Culture, Icu4n } (ICU optional). 19 | - MoneyContext.CreateScope(options => options.FormattingProvider = ...); 20 | - Options binder: allow IConfiguration binding to set defaults (e.g., from appsettings.json). 21 | 22 | ## Defaults and Probing 23 | - If CLDR package is referenced, default to Cldr; otherwise default to Culture. 24 | - Allow explicit override via DI or options. 25 | 26 | ## Error Handling 27 | - If chosen provider is not available (missing package), throw during service wiring with a clear message. 28 | 29 | ## Acceptance Criteria 30 | - Works across DI containers compatible with Microsoft.Extensions.*. 31 | - Supports named contexts or at least per-scope overrides via MoneyContext.CreateScope. 32 | - Clear documentation in docs/README.md and examples. 33 | -------------------------------------------------------------------------------- /tests/Benchmark/MoneyConvertingBenchmarks.cs: -------------------------------------------------------------------------------- 1 | using System.Data.SqlTypes; 2 | using BenchmarkDotNet.Attributes; 3 | using NodaMoney; 4 | 5 | namespace Benchmark; 6 | 7 | [MemoryDiagnoser] 8 | public class MoneyConvertingBenchmarks 9 | { 10 | readonly Money _euro = new(765.43m, "EUR"); 11 | readonly FastMoney _euroFast = new (765.43m, "EUR"); 12 | 13 | [Benchmark(Baseline = true)] 14 | public decimal ToDecimal() 15 | { 16 | return _euro.ToDecimal(); 17 | } 18 | 19 | [Benchmark] 20 | public double ToDouble() 21 | { 22 | return _euro.ToDouble(); 23 | } 24 | 25 | [Benchmark] 26 | public int ToIn32() 27 | { 28 | return _euro.ToInt32(); 29 | } 30 | 31 | [Benchmark] 32 | public long ToInt64() 33 | { 34 | return _euro.ToInt64(); 35 | } 36 | 37 | [Benchmark] 38 | public FastMoney ToFastMoney() 39 | { 40 | return new FastMoney(_euro); 41 | } 42 | 43 | [Benchmark] 44 | public decimal fToDecimal() 45 | { 46 | return _euroFast.ToDecimal(); 47 | } 48 | 49 | [Benchmark] 50 | public double fToDouble() 51 | { 52 | return _euroFast.ToDouble(); 53 | } 54 | 55 | [Benchmark] 56 | public int fToIn32() 57 | { 58 | return _euroFast.ToInt32(); 59 | } 60 | 61 | [Benchmark] 62 | public long fToInt64() 63 | { 64 | return _euroFast.ToInt64(); 65 | } 66 | 67 | [Benchmark] 68 | public Money fToMoney() 69 | { 70 | return _euroFast.ToMoney(); 71 | } 72 | 73 | [Benchmark] 74 | public SqlMoney fToSqlMoney() 75 | { 76 | return _euroFast.ToSqlMoney(); 77 | } 78 | 79 | [Benchmark] 80 | public long fToAOCurrency() 81 | { 82 | return _euroFast.ToOACurrency(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/ExchangeRateSpec/ConvertMoney.cs: -------------------------------------------------------------------------------- 1 | using NodaMoney.Exchange; 2 | 3 | namespace NodaMoney.Tests.ExchangeRateSpec; 4 | 5 | public class ConvertMoney 6 | { 7 | private readonly Currency _euro = Currency.FromCode("EUR"); 8 | 9 | private readonly Currency _dollar = Currency.FromCode("USD"); 10 | 11 | private ExchangeRate _exchangeRate = new(Currency.FromCode("EUR"), Currency.FromCode("USD"), 1.2591); 12 | // EUR/USD 1.2591 13 | 14 | [Fact] 15 | public void WhenConvertingEurosToDollars_ThenConversionShouldBeCorrect() 16 | { 17 | // When Converting €100,99 With EUR/USD 1.2591, Then Result Should Be $127.16 18 | 19 | // Convert €100,99 to $127.156509 (= €100.99 * 1.2591) 20 | var converted = _exchangeRate.Convert(Money.Euro(100.99M)); 21 | 22 | converted.Currency.Should().Be(_dollar); 23 | converted.Amount.Should().Be(127.16M); 24 | } 25 | 26 | [Fact] 27 | public void WhenConvertingEurosToDollarsAndThenBack_ThenEndResultShouldBeTheSameAsInTheBeginning() 28 | { 29 | // Convert €100,99 to $127.156509 (= €100.99 * 1.2591) 30 | var converted = _exchangeRate.Convert(Money.Euro(100.99M)); 31 | 32 | // Convert $127.16 to €100,99 (= $127.16 / 1.2591) 33 | var revert = _exchangeRate.Convert(converted); 34 | 35 | revert.Currency.Should().Be(_euro); 36 | revert.Amount.Should().Be(100.99M); 37 | } 38 | 39 | [Fact] 40 | public void WhenConvertingWithExchangeRateWithDifferentCurrencies_ThenThrowException() 41 | { 42 | // Arrange, Act 43 | Action action = () => _exchangeRate.Convert(Money.Yen(324)); 44 | 45 | // Assert 46 | action.Should().Throw().WithMessage("Money should have the same currency as the base currency or the quote currency!*"); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/NodaMoney.DependencyInjection.Tests/NodaMoney.DependencyInjection.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0;net9.0;net8.0;net48;net6.0 5 | latest 6 | enable 7 | enable 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneyRoundingSpec/ApplyMaxScale.cs: -------------------------------------------------------------------------------- 1 | using NodaMoney.Context; 2 | using NodaMoney.Tests.Helpers; 3 | 4 | namespace NodaMoney.Tests.MoneyRoundingSpec; 5 | 6 | [Collection(nameof(NoParallelization))] 7 | public class ApplyMaxScale 8 | { 9 | [Fact] 10 | public void DefaultScale() 11 | { 12 | // Arrange 13 | var amount = 1234.56789m; 14 | 15 | // Act 16 | var money = new Money(amount, "EUR"); 17 | 18 | // Assert 19 | money.Scale.Should().Be(2, because: "Default scale of EUR is 2"); 20 | money.Amount.Should().Be(1234.57m, because: "Rounding to 2 decimals"); 21 | } 22 | 23 | [Fact] 24 | public void MaxScaleSetToFour() 25 | { 26 | // Arrange 27 | var amount = 1234.56789m; 28 | MoneyContext context = MoneyContext.Create(options => options.MaxScale = 4); 29 | 30 | // Act 31 | MoneyContext.CreateScope(context); 32 | var money = new Money(amount, "EUR"); 33 | 34 | // Assert 35 | money.Context.Should().Be(context); 36 | money.Amount.Should().Be(1234.5679m, because: "Rounding to 4 decimals"); 37 | money.Scale.Should().Be(4, because: "MaxScale set to 4 in MoneyContext"); 38 | } 39 | 40 | [Fact] 41 | public void MaxScaleSetToSix() 42 | { 43 | // Arrange 44 | var amount = 1234.56789m; 45 | MoneyContext context = MoneyContext.Create(options => options.MaxScale = 6); 46 | 47 | // Act 48 | MoneyContext.CreateScope(context); 49 | var money = new Money(amount, "EUR"); 50 | 51 | // Assert 52 | money.Context.Should().Be(context); 53 | money.Amount.Should().Be(1234.56789m, because: "Rounding to 6 decimals"); 54 | money.Scale.Should().BeInRange(5, 6, because: "MaxScale set to 6 in MoneyContext"); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/Benchmark/BenchmarkDotNet.Artifacts/results/Benchmark.InitializingMoneyBenchmarks-report-github.md: -------------------------------------------------------------------------------- 1 | ``` 2 | 3 | BenchmarkDotNet v0.15.6, Windows 11 (10.0.26200.7171) 4 | AMD Ryzen 7 5800H with Radeon Graphics 3.20GHz, 1 CPU, 16 logical and 8 physical cores 5 | .NET SDK 10.0.100 6 | [Host] : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3 7 | DefaultJob : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3 8 | 9 | 10 | ``` 11 | | Method | Mean | Error | Op/s | Ratio | Gen0 | Allocated | Alloc Ratio | 12 | |------------------------------ |----------:|----------:|--------------:|------:|-------:|----------:|------------:| 13 | | CurrencyCode | 28.564 ns | 0.5243 ns | 35,008,927.4 | 1.00 | - | - | NA | 14 | | fCurrencyCode | 22.776 ns | 0.2386 ns | 43,905,942.1 | 0.80 | - | - | NA | 15 | | CurrencyCodeAndRoundingMode | 30.179 ns | 0.6230 ns | 33,135,089.8 | 1.06 | - | - | NA | 16 | | CurrencyCodeAndContext | 29.527 ns | 0.5400 ns | 33,867,685.3 | 1.03 | - | - | NA | 17 | | CurrencyFromCode | 28.356 ns | 0.5939 ns | 35,265,913.1 | 0.99 | - | - | NA | 18 | | CurrencyInfoFromCode | 28.415 ns | 0.5931 ns | 35,193,217.2 | 1.00 | - | - | NA | 19 | | ExtensionMethodEuro | 28.590 ns | 0.3620 ns | 34,976,875.1 | 1.00 | - | - | NA | 20 | | ImplicitCurrencyByConstructor | 73.518 ns | 1.5096 ns | 13,602,182.8 | 2.57 | 0.0038 | 32 B | NA | 21 | | ImplicitCurrencyByCasting | 74.238 ns | 1.4504 ns | 13,470,267.0 | 2.60 | 0.0038 | 32 B | NA | 22 | | Deconstruct | 1.108 ns | 0.0485 ns | 902,338,512.8 | 0.04 | - | - | NA | 23 | -------------------------------------------------------------------------------- /features/proposals/03-ambiguous-currency-symbol-policies.md: -------------------------------------------------------------------------------- 1 | # Ambiguous Currency Symbol Policies (Parsing & Formatting) 2 | 3 | Why it matters 4 | - Many currencies share symbols (e.g., "$"). Disambiguation should be deterministic, culture-aware, and optionally policy-driven. Also relates to zero-amount validation. 5 | 6 | Proposal 7 | - Introduce MoneyFormatOptions and MoneyParseOptions with explicit symbol resolution, strictness, and zero-currency policies. Tie to MoneyContext.DefaultCurrency and optional PreferredCurrencies per context. 8 | 9 | Possible implementation 10 | - New option records: 11 | - MoneyFormatOptions: Culture, UseIsoCode, UseSymbol, SymbolPlacement, SpacePolicy, NegativeStyle, Min/MaxDecimals, PreserveTrailingZeros, QuantizeToCurrencyMinorUnits, CashDisplay. 12 | - MoneyParseOptions: Culture, DefaultCurrency, Strict, SymbolResolutionPolicy (Fail|CultureBased|PreferList), PreferredCurrencies, ZeroCurrencyPolicy (Ignore|RequireMatch|InferDefault). 13 | - Parsing rules: 14 | - If symbol ambiguous, resolve by: PreferredCurrencies > Culture region mapping > MoneyContext.DefaultCurrency > fail (if Strict). 15 | - Recognize US$, CA$, A$ variants, trailing minus, parentheses, Unicode spaces/digits. 16 | - Formatting rules: 17 | - Presets for ISO vs symbol placement; provide NonBreaking/Narrow spaces where CLDR suggests. 18 | - CashDisplay toggle routes through CashDenominationRounding for display only. 19 | - Integration: 20 | - Extend Money.Parse/TryParse overloads to accept MoneyParseOptions. 21 | - CurrencyInfo formatter to accept MoneyFormatOptions presets. 22 | 23 | Risks / considerations 24 | - Culture-specific defaults must be well-documented. 25 | - Ensure backward compatibility with existing single-letter format specifiers. 26 | 27 | Open questions 28 | - Should PreferredCurrencies be part of MoneyContextOptions for global policy? 29 | - Do we need diagnostics (reason codes) on TryParse failure? 30 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneyFiveMostUsedCurrenciesSpec/CreateEuros.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.MoneyFiveMostUsedCurrenciesSpec; 2 | 3 | public class CreateEuros 4 | { 5 | [Fact] 6 | public void WhenDecimal_ThenCreatingShouldSucceed() 7 | { 8 | // from decimal (other integral types are implicitly converted to decimal) 9 | var euros = Money.Euro(10.00m); 10 | 11 | euros.Currency.Should().Be(Currency.FromCode("EUR")); 12 | euros.Amount.Should().Be(10.00m); 13 | } 14 | 15 | [Fact] 16 | public void WhenDecimalAndRoundingAwayFromZero_ThenCreatingShouldSucceed() 17 | { 18 | // from decimal (other integral types are implicitly converted to decimal) 19 | var euros1 = Money.Euro(10.005m); 20 | var euros2 = Money.Euro(10.005m, MidpointRounding.AwayFromZero); 21 | 22 | euros2.Currency.Should().Be(Currency.FromCode("EUR")); 23 | euros2.Amount.Should().Be(10.01m); 24 | euros1.Amount.Should().NotBe(euros2.Amount); 25 | } 26 | 27 | [Fact] 28 | public void WhenDouble_ThenCreatingShouldSucceed() 29 | { 30 | // from double (float is implicitly converted to double) 31 | var euros = Money.Euro(10.005D); 32 | 33 | euros.Currency.Should().Be(Currency.FromCode("EUR")); 34 | euros.Amount.Should().Be(10.00m); 35 | } 36 | 37 | [Fact] 38 | public void WhenLong_ThenCreatingShouldSucceed() 39 | { 40 | // from long (byte, short and int are implicitly converted to long) 41 | var euros = Money.Euro(10L); 42 | 43 | euros.Currency.Should().Be(Currency.FromCode("EUR")); 44 | euros.Amount.Should().Be(10.00m); 45 | } 46 | 47 | [Fact] 48 | public void WhenULong_ThenCreatingShouldSucceed() 49 | { 50 | var euros = Money.Euro(10UL); 51 | 52 | euros.Currency.Should().Be(Currency.FromCode("EUR")); 53 | euros.Amount.Should().Be(10.00m); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/Serialization/DataContractSerializerSpec/SerializeMoney.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Runtime.Serialization; 3 | using System.Xml; 4 | 5 | namespace NodaMoney.Tests.Serialization.DataContractSerializerSpec; 6 | 7 | public class SerializeMoney 8 | { 9 | private Money yen = new Money(765m, Currency.FromCode("JPY")); 10 | private Money euro = new Money(765.43m, Currency.FromCode("EUR")); 11 | 12 | [Fact] 13 | public void WhenSerializingYen_ThenThisShouldSucceed() 14 | { 15 | yen.Should().Be(Clone(yen)); 16 | } 17 | 18 | [Fact] 19 | public void WhenSerializingEuro_ThenThisShouldSucceed() 20 | { 21 | euro.Should().Be(Clone(euro)); 22 | } 23 | 24 | [Fact] 25 | public void WhenSerializingArticle_ThenThisShouldSucceed() 26 | { 27 | var article = new Order 28 | { 29 | Id = 123, 30 | Total = Money.Euro(27.15), 31 | Name = "Foo" 32 | }; 33 | 34 | article.Total.Should().Be(Clone(article).Total); 35 | } 36 | 37 | public static Stream Serialize(object source) 38 | { 39 | Stream stream = new MemoryStream(); 40 | var serializer = new DataContractSerializer(source.GetType()); 41 | serializer.WriteObject(stream, source); 42 | return stream; 43 | } 44 | 45 | public static T Deserialize(Stream stream) 46 | { 47 | stream.Position = 0L; 48 | using (var reader = XmlDictionaryReader.CreateTextReader(stream, new XmlDictionaryReaderQuotas())) 49 | { 50 | var serializer = new DataContractSerializer(typeof(T)); 51 | return (T)serializer.ReadObject(reader, true); 52 | } 53 | } 54 | 55 | private static T Clone(object source) 56 | { 57 | // Console.WriteLine(Serialize(source).ToString()); 58 | return Deserialize(Serialize(source)); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/Benchmark/MoneyEqualBenchmarks.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Attributes; 2 | using BenchmarkDotNet.Configs; 3 | using NodaMoney; 4 | 5 | namespace Benchmark; 6 | 7 | [MemoryDiagnoser] 8 | public class MoneyEqualBenchmarks 9 | { 10 | readonly Money _euro10 = Money.Euro(10); 11 | readonly Money _euro20 = Money.Euro(20); 12 | readonly Money _dollar10 = Money.USDollar(10); 13 | Money _euro = new Money(765.43m, "EUR"); 14 | readonly FastMoney _euro10fast = new(10, "EUR"); 15 | readonly FastMoney _euro20fast = new(20, "EUR"); 16 | readonly FastMoney _dollar10fast = new(10, "USD"); 17 | 18 | [Benchmark(Baseline = true)] 19 | public bool Equal() 20 | { 21 | return _euro10 == _euro10; // false 22 | } 23 | 24 | [Benchmark] 25 | public bool NotEqualValue() 26 | { 27 | return _euro10 == _euro20; // false 28 | } 29 | 30 | [Benchmark] 31 | public bool NotEqualCurrency() 32 | { 33 | return _euro10 == _dollar10; // false 34 | } 35 | 36 | [Benchmark] 37 | public bool EqualOrBigger() 38 | { 39 | return _euro20 >= _euro10; // true 40 | } 41 | 42 | [Benchmark] 43 | public bool Bigger() 44 | { 45 | return _euro20 > _euro10; // true 46 | } 47 | 48 | [Benchmark] 49 | public bool fEqual() 50 | { 51 | return _euro10fast == _euro10fast; // true 52 | } 53 | 54 | [Benchmark] 55 | public bool fNotEqualValue() 56 | { 57 | return _euro10fast == _euro20fast; // false 58 | } 59 | 60 | [Benchmark] 61 | public bool fNotEqualCurrency() 62 | { 63 | return _euro10fast == _dollar10fast; // false 64 | } 65 | 66 | [Benchmark] 67 | public bool fEqualOrBigger() 68 | { 69 | return _euro20fast >= _euro10fast; // true 70 | } 71 | 72 | [Benchmark] 73 | public bool fBigger() 74 | { 75 | return _euro20fast > _euro10fast; // true 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /features/proposals/rounding-strategy-vs-provider-and-operators.md: -------------------------------------------------------------------------------- 1 | # Rounding Strategy vs Provider, and Money Operators/Queries 2 | 3 | Summary 4 | - IRoundingStrategy is the concrete algorithm that performs rounding. IRoundingProvider is a selector/factory that returns an IRoundingStrategy for a given RoundingQuery (currency, cash, scale, date, attributes). Support both delegate- and interface-based operator/query APIs. 5 | 6 | Proposed abstractions 7 | - IRoundingProvider 8 | - IRoundingStrategy GetRounding(in RoundingQuery query) 9 | - RoundingQuery (readonly record struct) 10 | - CurrencyInfo? Currency, bool IsCash = false, int? Scale = null, DateTimeOffset? When = null, IReadOnlyDictionary? Attributes = null 11 | - MoneyContext integration 12 | - RoundingStrategy => Options.RoundingStrategy ?? Options.RoundingProvider?.GetRounding(BuildQuery()) ?? StandardRounding.ToEven 13 | 14 | Operators/Queries 15 | - IMoneyOperator and IMoneyQuery interfaces with With/Query extension methods. Also support delegates for ergonomic scenarios. 16 | - Provide readonly struct implementations like PercentageOperator; register via DI when desired. 17 | 18 | Code sketch 19 | ```csharp 20 | public interface IRoundingProvider 21 | { 22 | IRoundingStrategy GetRounding(in RoundingQuery query); 23 | } 24 | 25 | public readonly record struct RoundingQuery( 26 | CurrencyInfo? Currency, 27 | bool IsCash = false, 28 | int? Scale = null, 29 | DateTimeOffset? When = null, 30 | IReadOnlyDictionary? Attributes = null 31 | ); 32 | 33 | public interface IMoneyOperator { Money Apply(in Money value, MoneyContext? context = null); } 34 | public interface IMoneyQuery { T Evaluate(in Money value, MoneyContext? context = null); } 35 | ``` 36 | 37 | Design notes 38 | - Keep fast paths by caching resolved strategy when explicit in MoneyContextOptions. 39 | - Prefer readonly structs for operators to avoid heap allocations and be AOT friendly. 40 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneyUnaryOperatorsSpec/IncrementAndDecrementMoneyUnary.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.MoneyUnaryOperatorsSpec; 2 | 3 | public class IncrementAndDecrementMoneyUnary 4 | { 5 | [Theory] 6 | [MemberData(nameof(TestData))] 7 | public void WhenIncrementing_ThenAmountShouldIncrementWithMinorUnit(Money money, Currency expectedCurrency, decimal expectedDifference) 8 | { 9 | decimal amountBefore = money.Amount; 10 | 11 | Money result = ++money; 12 | 13 | result.Currency.Should().Be(expectedCurrency); 14 | result.Amount.Should().Be(amountBefore + expectedDifference); 15 | money.Currency.Should().Be(expectedCurrency); 16 | money.Amount.Should().Be(amountBefore + expectedDifference); 17 | } 18 | 19 | [Theory] 20 | [MemberData(nameof(TestData))] 21 | public void WhenDecrementing_ThenAmountShouldDecrementWithMinorUnit(Money money, Currency expectedCurrency, decimal expectedDifference) 22 | { 23 | decimal amountBefore = money.Amount; 24 | 25 | Money result = --money; 26 | 27 | result.Currency.Should().Be(expectedCurrency); 28 | result.Amount.Should().Be(amountBefore - expectedDifference); 29 | money.Currency.Should().Be(expectedCurrency); 30 | money.Amount.Should().Be(amountBefore - expectedDifference); 31 | } 32 | 33 | public static TheoryData TestData => new TheoryData 34 | { 35 | { new Money(765m, Currency.FromCode("JPY")), Currency.FromCode("JPY"), Currency.FromCode("JPY").MinimalAmount }, 36 | { new Money(765.43m, Currency.FromCode("EUR")), Currency.FromCode("EUR"), Currency.FromCode("EUR").MinimalAmount }, 37 | { new Money(765.43m, Currency.FromCode("USD")), Currency.FromCode("USD"), Currency.FromCode("USD").MinimalAmount }, 38 | { new Money(765.432m, Currency.FromCode("BHD")), Currency.FromCode("BHD"), Currency.FromCode("BHD").MinimalAmount } 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/NodaMoney/MinorUnit.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney; 2 | 3 | /// 4 | /// The MinorUnit enum represents the minor unit as the power oo ten of a currency. The minor unit specifies the number of decimal places used 5 | /// when representing fractional amounts of a currency. Each value of the MinorUnit enum corresponds to a specific number 6 | /// of decimal places, ranging from 0 to 13. 7 | /// 8 | /// store minor unit in 4 bits (0-15). we can use 5 bits (0-31) // Power of 10, Math.Log10(2); 9 | /// Max scale of is 28. 10 | public enum MinorUnit : byte 11 | { 12 | #pragma warning disable RCS1181 13 | Zero = 0, // 10^0 = 0 minor units 14 | One = 1, // 10^1 = 10 minor units 15 | Two = 2, // 10^2 = 100 minor units 16 | Three = 3, // 10^3 = 1.000 minor units 17 | Four = 4, // 10^4 = 10.000 minor units 18 | Five = 5, 19 | Six = 6, 20 | Seven = 7, 21 | Eight = 8, 22 | Nine = 9, 23 | Ten = 10, 24 | Eleven = 11, 25 | Twelve = 12, 26 | Thirteen = 13, 27 | Fourteen = 14, 28 | Fifteen = 15, 29 | Sixteen = 16, 30 | Seventeen = 17, 31 | Eighteen = 18, 32 | Nineteen = 19, 33 | Twenty = 20, 34 | TwentyOne = 21, 35 | TwentyTwo = 22, 36 | TwentyThree = 23, 37 | TwentyFour = 24, 38 | TwentyFive = 25, 39 | TwentySix = 26, 40 | TwentySeven = 27, 41 | TwentyEight = 28, 42 | /// 43 | /// Mauritania does not use a decimal division of units, setting 1 ouguiya (UM) equal to 5 khoums, and Madagascar has 1 ariary = 44 | /// 5 iraimbilanja. The coins display "1/5" on their face and are referred to as a "fifth". These are not used in practice, but when 45 | /// written out, a single significant digit is used. E.g., 1.2 UM. 46 | /// 47 | OneFifth = 254, // 1/5 = 10^log10(5) = 10^0.698970004 48 | NotApplicable = 255, // For N.A. we use 10^0 = 0 minor units 49 | #pragma warning restore RCS1181 50 | } 51 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/CurrencyInfoSpec/InitiateInternallyACurrency.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.CurrencyInfoSpec; 2 | 3 | public class InitiateInternallyACurrency 4 | { 5 | [Fact] 6 | public void WhenParamsAreCorrect_ThenCreatingShouldSucceed() 7 | { 8 | var eur = new CurrencyInfo("EUR", 978, MinorUnit.Two, "Euro", "€"); 9 | 10 | eur.Code.Should().Be("EUR"); 11 | eur.Number.Should().Be(978); 12 | eur.DecimalDigits.Should().Be(2); 13 | eur.EnglishName.Should().Be("Euro"); 14 | eur.Symbol.Should().Be("€"); 15 | } 16 | 17 | [Fact] 18 | public void WhenCodeIsNull_ThenCreatingShouldThrow() 19 | { 20 | Action action = () => new CurrencyInfo(null!, 978, MinorUnit.Two, "Euro", "€"); 21 | 22 | action.Should().Throw(); 23 | } 24 | 25 | [Fact] 26 | public void WhenNumberIsNull_ThenNumberShouldDefaultToEmpty() 27 | { 28 | //var eur = new Currency("EUR", null, 2, "Euro", "€"); 29 | 30 | //eur.Number.Should().Be(string.Empty); 31 | 32 | var eur = new CurrencyInfo("EUR", 0, MinorUnit.Two, "Euro", "€"); 33 | 34 | eur.Number.Should().Be(0); 35 | } 36 | 37 | [Fact] 38 | public void WhenEnglishNameIsNull_ThenEnglishNameShouldDefaultToEmpty() 39 | { 40 | var eur = new CurrencyInfo("EUR", 978, MinorUnit.Two, null, "€"); 41 | 42 | eur.EnglishName.Should().Be(string.Empty); 43 | } 44 | 45 | [Fact] 46 | public void WhenSignIsNull_ThenSignShouldDefaultToGenericCurrencySign() 47 | { 48 | var eur = new CurrencyInfo("EUR", 978, MinorUnit.Two, "Euro", null); 49 | 50 | eur.Symbol.Should().Be(CurrencyInfo.GenericCurrencySign); 51 | } 52 | 53 | [Fact] 54 | public void WhenDecimalDigitIsLowerThenMinusOne_ThenCreatingShouldThrow() 55 | { 56 | //Action action = () => { var eur = new Currency("EUR", 978, -2, "Euro", "€"); }; 57 | 58 | //action.Should().Throw(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneyFiveMostUsedCurrenciesSpec/CreateYens.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.MoneyFiveMostUsedCurrenciesSpec; 2 | 3 | public class CreateYens 4 | { 5 | [Fact] 6 | public void WhenDecimal_ThenCreatingShouldSucceed() 7 | { 8 | //from decimal (other integral types are implicitly converted to decimal) 9 | var yens = Money.Yen(10.00m); 10 | 11 | yens.Should().NotBeNull(); 12 | yens.Currency.Should().Be(Currency.FromCode("JPY")); 13 | yens.Amount.Should().Be(10.00m); 14 | } 15 | 16 | [Fact] 17 | public void WhenDecimalAndRoundingAwayFromZero_ThenCreatingShouldSucceed() 18 | { 19 | //from decimal (other integral types are implicitly converted to decimal) 20 | var yen1 = Money.Yen(10.5m); 21 | var yen2 = Money.Yen(10.5m, MidpointRounding.AwayFromZero); 22 | 23 | yen2.Currency.Should().Be(Currency.FromCode("JPY")); 24 | yen2.Amount.Should().Be(11m); 25 | yen1.Amount.Should().NotBe(yen2.Amount); 26 | } 27 | 28 | [Fact] 29 | public void WhenDouble_ThenCreatingShouldSucceed() 30 | { 31 | //from double (float is implicitly converted to double) 32 | var yens = Money.Yen(10.5D); 33 | 34 | yens.Should().NotBeNull(); 35 | yens.Currency.Should().Be(Currency.FromCode("JPY")); 36 | yens.Amount.Should().Be(10.00m); 37 | } 38 | 39 | [Fact] 40 | public void WhenLong_ThenCreatingShouldSucceed() 41 | { 42 | //from long (byte, short and int are implicitly converted to long) 43 | var yens = Money.Yen(10L); 44 | 45 | yens.Should().NotBeNull(); 46 | yens.Currency.Should().Be(Currency.FromCode("JPY")); 47 | yens.Amount.Should().Be(10.00m); 48 | } 49 | 50 | [Fact] 51 | public void WhenULong_ThenCreatingShouldSucceed() 52 | { 53 | var yens = Money.Yen(10UL); 54 | 55 | yens.Should().NotBeNull(); 56 | yens.Currency.Should().Be(Currency.FromCode("JPY")); 57 | yens.Amount.Should().Be(10.00m); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/Benchmark/InitializingMoneyBenchmarks.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Attributes; 2 | using NodaMoney; 3 | using NodaMoney.Context; 4 | 5 | namespace Benchmark; 6 | 7 | [MemoryDiagnoser] 8 | public class InitializingMoneyBenchmarks 9 | { 10 | readonly Currency _euro = CurrencyInfo.FromCode("EUR"); 11 | readonly Money _money = new Money(10m, "EUR"); 12 | 13 | [Benchmark(Baseline = true)] 14 | public Money CurrencyCode() 15 | { 16 | return new Money(6.54m, "EUR"); 17 | } 18 | 19 | [Benchmark] 20 | public FastMoney fCurrencyCode() 21 | { 22 | return new FastMoney(6.54m, "EUR"); 23 | } 24 | 25 | [Benchmark] 26 | public Money CurrencyCodeAndRoundingMode() 27 | { 28 | return new Money(765.425m, "EUR", MidpointRounding.AwayFromZero); 29 | } 30 | 31 | [Benchmark] 32 | public Money CurrencyCodeAndContext() 33 | { 34 | MoneyContext ctx = MoneyContext.DefaultThreadContext; 35 | return new Money(765.425m, "EUR", ctx); 36 | } 37 | 38 | [Benchmark] 39 | public Money CurrencyFromCode() 40 | { 41 | #pragma warning disable CS0618 // Type or member is obsolete 42 | return new Money(6.54m, Currency.FromCode("EUR")); 43 | #pragma warning restore CS0618 // Type or member is obsolete 44 | } 45 | 46 | [Benchmark] 47 | public Money CurrencyInfoFromCode() 48 | { 49 | return new Money(6.54m, CurrencyInfo.FromCode("EUR")); 50 | } 51 | 52 | [Benchmark] 53 | public Money ExtensionMethodEuro() 54 | { 55 | return Money.Euro(6.54m); 56 | } 57 | 58 | [Benchmark] 59 | public Money ImplicitCurrencyByConstructor() 60 | { 61 | return new Money(6.54m); 62 | } 63 | 64 | [Benchmark] 65 | public Money ImplicitCurrencyByCasting() 66 | { 67 | Money money = (Money)6.54m; 68 | return money; 69 | } 70 | 71 | [Benchmark] 72 | public (decimal, Currency) Deconstruct() 73 | { 74 | var (amount, currency) = _money; 75 | return (amount, currency); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneyFiveMostUsedCurrenciesSpec/CreateDollars.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.MoneyFiveMostUsedCurrenciesSpec; 2 | 3 | public class CreateDollars 4 | { 5 | [Fact] 6 | public void WhenDecimal_ThenCreatingShouldSucceed() 7 | { 8 | //from decimal (other integral types are implicitly converted to decimal) 9 | var dollars = Money.USDollar(10.00m); 10 | 11 | dollars.Currency.Should().Be(Currency.FromCode("USD")); 12 | dollars.Amount.Should().Be(10.00m); 13 | } 14 | 15 | [Fact] 16 | public void WhenDecimalAndRoundingAwayFromZero_ThenCreatingShouldSucceed() 17 | { 18 | // from decimal (other integral types are implicitly converted to decimal) 19 | var dollars1 = Money.USDollar(10.005m); 20 | var dollars2 = Money.USDollar(10.005m, MidpointRounding.AwayFromZero); 21 | 22 | dollars2.Currency.Should().Be(Currency.FromCode("USD")); 23 | dollars2.Amount.Should().Be(10.01m); 24 | dollars1.Amount.Should().NotBe(dollars2.Amount); 25 | } 26 | 27 | [Fact] 28 | public void WhenDouble_ThenCreatingShouldSucceed() 29 | { 30 | //from double (float is implicitly converted to double) 31 | var dollars = Money.USDollar(10.005D); 32 | 33 | dollars.Currency.Should().Be(Currency.FromCode("USD")); 34 | dollars.Amount.Should().Be(10.00m); 35 | } 36 | 37 | [Fact] 38 | public void WhenLong_ThenCreatingShouldSucceed() 39 | { 40 | //from long (byte, short and int are implicitly converted to long) 41 | var dollars = Money.USDollar(10L); 42 | 43 | dollars.Currency.Should().Be(Currency.FromCode("USD")); 44 | dollars.Amount.Should().Be(10.00m); 45 | } 46 | 47 | [Fact] 48 | public void WhenULong_ThenCreatingShouldSucceed() 49 | { 50 | //from long (byte, short and int are implicitly converted to long) 51 | var dollars = Money.USDollar(10UL); 52 | 53 | dollars.Currency.Should().Be(Currency.FromCode("USD")); 54 | dollars.Amount.Should().Be(10.00m); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/Iso4127Spec/Iso4127ListFixture.cs: -------------------------------------------------------------------------------- 1 | using System.Xml.Linq; 2 | 3 | namespace NodaMoney.Tests.Iso4127Spec; 4 | 5 | public class Iso4127ListFixture 6 | { 7 | public Iso4127Currency[] Currencies { get; private set; } 8 | public DateTime PublishDate { get; private set; } 9 | 10 | public Iso4127ListFixture() 11 | { 12 | const string fileName = "iso4127.xml"; 13 | const string listOneUrl = "https://www.six-group.com/dam/download/financial-information/data-center/iso-currrency/lists/list-one.xml"; 14 | 15 | // ReSharper disable once RedundantNameQualifier - required for .NET 4.8 16 | using System.Net.Http.HttpClient client = new(); 17 | using (Stream contentStream = client.GetStreamAsync(listOneUrl).GetAwaiter().GetResult()) 18 | { 19 | // Download ISO-4127 XML as a file 20 | using FileStream fileStream = new(fileName, FileMode.Create, FileAccess.Write, FileShare.None); 21 | contentStream.CopyTo(fileStream); 22 | } 23 | 24 | // Parse XML 25 | var document = XDocument.Load(fileName); 26 | 27 | Currencies = document.Element("ISO_4217")!.Element("CcyTbl")!.Elements("CcyNtry") 28 | .Select(e => 29 | new Iso4127Currency 30 | { 31 | CountryName = e.Element("CtryNm")!.Value, 32 | CurrencyName = e.Element("CcyNm")?.Value, 33 | Currency = e.Element("Ccy")?.Value, 34 | CurrencyNumber = e.Element("CcyNbr")?.Value, 35 | CurrencyMinorUnits = e.Element("CcyMnrUnts")?.Value 36 | }) 37 | .Where(a => !string.IsNullOrEmpty(a.Currency)) // ignore currencies without a currency name 38 | .ToArray(); 39 | 40 | PublishDate = DateTime.Parse(document.Element("ISO_4217")!.Attribute("Pblshd")!.Value); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/ExchangeRateSpec/CompareExchangeRates.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using NodaMoney.Exchange; 3 | 4 | namespace NodaMoney.Tests.ExchangeRateSpec; 5 | 6 | public class CompareExchangeRates 7 | { 8 | public static IEnumerable TestData => new[] 9 | { 10 | new object[] { new ExchangeRate("EUR", "USD", 1.2591), new ExchangeRate("EUR", "USD", 1.2591), true }, 11 | new object[] { new ExchangeRate("EUR", "USD", 0.0), new ExchangeRate("EUR", "USD", 0.0), true }, 12 | new object[] { new ExchangeRate("EUR", "USD", 1.2591), new ExchangeRate("EUR", "USD", 1.600), false }, 13 | new object[] { new ExchangeRate("EUR", "USD", 1.2591), new ExchangeRate("EUR", "AFN", 1.2591), false }, 14 | new object[] { new ExchangeRate("AFN", "USD", 1.2591), new ExchangeRate("EUR", "USD", 1.2591), false } 15 | }; 16 | 17 | [Theory][MemberData(nameof(TestData))] 18 | public void WhenTheAreEqual_ThenComparingShouldBeTrueOtherwiseFalse(ExchangeRate fx1, ExchangeRate fx2, bool areEqual) 19 | { 20 | if (areEqual) 21 | fx1.Should().Be(fx2); 22 | else 23 | fx1.Should().NotBe(fx2); 24 | 25 | if (areEqual) 26 | fx1.GetHashCode().Should().Be(fx2.GetHashCode()); //using GetHashCode() 27 | else 28 | fx1.GetHashCode().Should().NotBe(fx2.GetHashCode()); //using GetHashCode() 29 | 30 | fx1.Equals(fx2).Should().Be(areEqual); //using Equal() 31 | ExchangeRate.Equals(fx1, fx2).Should().Be(areEqual); //using static Equals() 32 | (fx1 == fx2).Should().Be(areEqual); //using Equality operators 33 | (fx1 != fx2).Should().Be(!areEqual); //using Equality operators 34 | } 35 | 36 | [Fact] 37 | public void WhenDefault_ThenNoCurrencyAndZero() 38 | { 39 | // Arrange 40 | 41 | // Act 42 | ExchangeRate fx = default; 43 | 44 | // Assert 45 | fx.BaseCurrency.Should().Be(Currency.NoCurrency); 46 | fx.QuoteCurrency.Should().Be(Currency.NoCurrency); 47 | fx.Value.Should().Be(0); 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneyFormattableSpec/CurrencySymbolSpecifier.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using NodaMoney.Tests.Helpers; 3 | 4 | namespace NodaMoney.Tests.MoneyFormattableSpec; 5 | 6 | public class CurrencySymbolSpecifier 7 | { 8 | [Fact] 9 | [UseCulture("en-US")] 10 | public void Uppercase_C_ShouldUseLocalCurrencySymbol() 11 | { 12 | var usd = CurrencyInfo.FromCode("USD"); 13 | var money = new Money(1234.56m, usd); 14 | 15 | money.ToString("C", CultureInfo.CurrentCulture).Should().Be("$1,234.56"); 16 | money.ToString("C0", CultureInfo.CurrentCulture).Should().Be("$1,235"); 17 | money.ToString("C3", CultureInfo.CurrentCulture).Should().Be("$1,234.560"); 18 | } 19 | 20 | [Fact] 21 | [UseCulture("en-US")] 22 | public void Uppercase_I_ShouldUseInternationalCurrencySymbol() 23 | { 24 | var usd = CurrencyInfo.FromCode("USD"); 25 | var money = new Money(1234.56m, usd); 26 | 27 | money.ToString("I", CultureInfo.CurrentCulture).Should().Be("US$1,234.56"); 28 | money.ToString("I0", CultureInfo.CurrentCulture).Should().Be("US$1,235"); 29 | money.ToString("I3", CultureInfo.CurrentCulture).Should().Be("US$1,234.560"); 30 | } 31 | 32 | [Fact] 33 | [UseCulture("fr-FR")] 34 | public void InFrenchCulture_C_ShouldUseLocalPlacementAndSymbol() 35 | { 36 | var cad = CurrencyInfo.FromCode("CAD"); 37 | var money = new Money(1234.56m, cad); 38 | 39 | // fr-FR places symbol after the number with a (narrow no‑break) space and uses comma as decimal separator 40 | money.ToString("C", CultureInfo.CurrentCulture).Should().Be("1\u202F234,56 $"); 41 | } 42 | 43 | [Fact] 44 | [UseCulture("fr-FR")] 45 | public void InFrenchCulture_I_ShouldUseInternationalSymbolAndPlacement() 46 | { 47 | var cad = CurrencyInfo.FromCode("CAD"); 48 | var money = new Money(1234.56m, cad); 49 | 50 | // International symbol for CAD is CA$ 51 | money.ToString("I", CultureInfo.CurrentCulture).Should().Be("1\u202F234,56 CA$"); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneyFiveMostUsedCurrenciesSpec/CreateYuan.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.MoneyFiveMostUsedCurrenciesSpec; 2 | 3 | public class CreateYuan 4 | { 5 | [Fact] 6 | public void WhenDecimal_ThenCreatingShouldSucceed() 7 | { 8 | //from decimal (other integral types are implicitly converted to decimal) 9 | var pounds = Money.Yuan(10.00m); 10 | 11 | pounds.Should().NotBeNull(); 12 | pounds.Currency.Should().Be(Currency.FromCode("CNY")); 13 | pounds.Amount.Should().Be(10.00m); 14 | } 15 | 16 | [Fact] 17 | public void WhenDecimalAndRoundingAwayFromZero_ThenCreatingShouldSucceed() 18 | { 19 | //from decimal (other integral types are implicitly converted to decimal) 20 | var pounds1 = Money.Yuan(10.005m); 21 | var pounds2 = Money.Yuan(10.005m, MidpointRounding.AwayFromZero); 22 | 23 | pounds2.Currency.Should().Be(Currency.FromCode("CNY")); 24 | pounds2.Amount.Should().Be(10.01m); 25 | pounds1.Amount.Should().NotBe(pounds2.Amount); 26 | } 27 | 28 | [Fact] 29 | public void WhenDouble_ThenCreatingShouldSucceed() 30 | { 31 | //from double (float is implicitly converted to double) 32 | var pounds = Money.Yuan(10.005D); 33 | 34 | pounds.Should().NotBeNull(); 35 | pounds.Currency.Should().Be(Currency.FromCode("CNY")); 36 | pounds.Amount.Should().Be(10.00m); 37 | } 38 | 39 | [Fact] 40 | public void WhenLong_ThenCreatingShouldSucceed() 41 | { 42 | //from long (byte, short and int are implicitly converted to long) 43 | var pounds = Money.Yuan(10L); 44 | 45 | pounds.Should().NotBeNull(); 46 | pounds.Currency.Should().Be(Currency.FromCode("CNY")); 47 | pounds.Amount.Should().Be(10.00m); 48 | } 49 | 50 | [Fact] 51 | public void WhenULong_ThenCreatingShouldSucceed() 52 | { 53 | var pounds = Money.Yuan(10UL); 54 | 55 | pounds.Should().NotBeNull(); 56 | pounds.Currency.Should().Be(Currency.FromCode("CNY")); 57 | pounds.Amount.Should().Be(10.00m); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /features/proposals/split-design.md: -------------------------------------------------------------------------------- 1 | # Split (Allocation/Proration) – Detailed Design 2 | 3 | Goal 4 | - Provide Joda-Money–style allocation/proration with NodaMoney ergonomics via Split(...). Precise, deterministic, context-aware, and fast. 5 | 6 | API shape 7 | ```csharp 8 | public enum SplitRemainderPolicy 9 | { 10 | LargestRemainder, 11 | RoundRobin, 12 | TowardZero, 13 | AwayFromZero 14 | } 15 | 16 | public static class MoneyExtensions 17 | { 18 | public static Money[] Split(this Money amount, int parts, 19 | SplitRemainderPolicy policy = SplitRemainderPolicy.LargestRemainder, 20 | MoneyContext? context = null); 21 | 22 | public static Money[] Split(this Money amount, params int[] ratios); 23 | public static Money[] Split(this Money amount, SplitRemainderPolicy policy, params int[] ratios); 24 | 25 | public static Money[] SplitByWeights(this Money amount, ReadOnlySpan weights, 26 | SplitRemainderPolicy policy = SplitRemainderPolicy.LargestRemainder, 27 | MoneyContext? context = null); 28 | 29 | public static Money[] SplitExact(this Money amount, ReadOnlySpan partsTemplate); 30 | } 31 | ``` 32 | 33 | Algorithm highlights 34 | - Determine scale via MoneyContext (or currency.MinorUnit). Quantize original amount using context rounding, then operate in smallest units (long) to ensure exactness. 35 | - Initialize base share for each part; distribute remainder units per policy. Support negative totals by adjusting step/sign. 36 | 37 | Remainder policies 38 | - LargestRemainder: rank by fractional parts of proportional shares; stable tie-breaking by index. 39 | - RoundRobin: distribute one unit at a time in order. 40 | - TowardZero: leave remainder undistributed (document that sums may differ). 41 | - AwayFromZero: bias to increase magnitude, compensating at the end. 42 | 43 | Edge cases 44 | - Zero or near-zero totals; negative amounts; zero ratios; overflow guards for extreme inputs. 45 | 46 | Tests 47 | - See features/PossibleFeatures.md Section “Split Design” for xUnit + FluentAssertions examples verifying sums, ordering, negatives, and TowardZero behavior. 48 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneyParsableSpec/ParseAllCurrencySymbolsAndCodes.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using NodaMoney.Tests.Helpers; 3 | 4 | namespace NodaMoney.Tests.MoneyParsableSpec; 5 | 6 | [Collection(nameof(NoParallelization))] 7 | public class ParseAllCurrencySymbolsAndCodes 8 | { 9 | [Fact] 10 | public void WhenParsingSymbol() 11 | { 12 | foreach (var currencyInfo in CurrencyInfo.GetAllCurrencies().Where(c => c.Symbol != CurrencyInfo.NoCurrency.Symbol)) 13 | { 14 | string toParse = $"1 {currencyInfo.Symbol}"; 15 | 16 | Money money = Money.Parse(toParse, currencyInfo); 17 | money.Currency.Should().Be((Currency)currencyInfo); 18 | } 19 | } 20 | 21 | [Fact] 22 | public void WhenParsingInternationalSymbol() 23 | { 24 | foreach (var currencyInfo in CurrencyInfo.GetAllCurrencies().Where(c => c.InternationalSymbol != CurrencyInfo.NoCurrency.Symbol)) 25 | { 26 | string toParse = $"1 {currencyInfo.InternationalSymbol}"; 27 | 28 | Money money = Money.Parse(toParse, currencyInfo); 29 | money.Currency.Should().Be((Currency)currencyInfo); 30 | } 31 | } 32 | 33 | [Fact] 34 | public void WhenParsingAlternativeSymbol() 35 | { 36 | foreach (var currencyInfo in CurrencyInfo.GetAllCurrencies().Where(c => c.AlternativeSymbols.Any())) 37 | { 38 | foreach (var alternativeSymbol in currencyInfo.AlternativeSymbols) 39 | { 40 | string toParse = $"1 {alternativeSymbol}"; 41 | 42 | Money money = Money.Parse(toParse, currencyInfo); 43 | money.Currency.Should().Be((Currency)currencyInfo); 44 | } 45 | } 46 | } 47 | 48 | [Fact] 49 | public void WhenParsingCodes() 50 | { 51 | foreach (var currencyInfo in CurrencyInfo.GetAllCurrencies()) 52 | { 53 | string toParse = $"1 {currencyInfo.Code}"; 54 | 55 | Money money = Money.Parse(toParse, currencyInfo); 56 | money.Currency.Should().Be((Currency)currencyInfo); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneyRoundingSpec/MoneyCalculations.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.MoneyRoundingSpec; 2 | 3 | public class MoneyCalculations 4 | { 5 | [Theory] 6 | [InlineData(1000, "XXX", 150)] 7 | [InlineData(1000, "EUR", 150)] 8 | public void RepeatedMultiplyDivideOneStep(decimal amount, string currency, decimal expectedResult) 9 | { 10 | // Arrange 11 | Money subject = new(amount, currency); 12 | 13 | // Act 14 | var result = subject * 15 / 100; 15 | 16 | // Assert 17 | result.Amount.Should().Be(expectedResult); // Test that 1000 * 15 / 100 == 150 18 | } 19 | 20 | [Theory] 21 | [InlineData(1000, "XXX", 150)] 22 | [InlineData(1000, "EUR", 150)] 23 | public void RepeatedMultiplyDivideTwoStep(decimal amount, string currency, decimal expectedResult) 24 | { 25 | // Arrange 26 | Money subject = new(amount, currency); 27 | 28 | // Act 29 | var intermediate = subject * 15; 30 | var result = intermediate / 100; 31 | 32 | // Assert 33 | result.Amount.Should().Be(expectedResult); // Test that 1000 * 15 / 100 == 150 34 | } 35 | 36 | [Fact] 37 | public void Accumulation() 38 | { 39 | // Arrange 40 | Money value = new Money(1m, "USD"); 41 | 42 | // Act 43 | for (int i = 0; i < 10000; i++) 44 | { 45 | value += 0.01m; 46 | } 47 | 48 | // Assert 49 | value.Amount.Should().Be(101m); // Expect exact addition without accumulative error 50 | } 51 | 52 | [Theory] 53 | [InlineData(1234.56789, "XXX", 0.00001, 1234.56788)] 54 | [InlineData(1234.56789, "EUR", 0.00001, 1234.57)] 55 | public void SubtractionWithCloseValues(decimal amount, string currency, decimal smallDifference, decimal expectedResult) 56 | { 57 | // Arrange 58 | Money value = new(amount, currency); 59 | 60 | // Act 61 | Money result = value - new Money(smallDifference, value.Currency); 62 | 63 | // Assert 64 | result.Amount.Should().Be(expectedResult); // Accuracy when values are close 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /features/proposals/01-rounding-provider-ecosystem.md: -------------------------------------------------------------------------------- 1 | # Rounding Provider Ecosystem (JSR 354 parity) 2 | 3 | Why it matters 4 | - JavaMoney supports pluggable RoundingProvider discovered via SPI and selected via RoundingQuery attributes (currency, cash vs electronic, scale, custom flags). Unlocks dynamic, policy-driven rounding. 5 | 6 | Proposal 7 | - Keep IRoundingStrategy as the algorithm. Add IRoundingProvider as a selector/factory that returns the strategy for a given query. Integrate provider lookup into MoneyContext with fast-path caching when an explicit strategy is set. 8 | 9 | Possible implementation 10 | - New abstractions: 11 | - interface IRoundingProvider { IRoundingStrategy GetRounding(in RoundingQuery query); } 12 | - readonly record struct RoundingQuery(CurrencyInfo? Currency, bool IsCash = false, int? Scale = null, DateTimeOffset? When = null, IReadOnlyDictionary? Attributes = null) 13 | - MoneyContext integration: 14 | - MoneyContextOptions: add IRoundingProvider? RoundingProvider; bool IsCashTransaction; IReadOnlyDictionary? Attributes. 15 | - MoneyContext.RoundingStrategy: resolve as Options.RoundingStrategy ?? Options.RoundingProvider?.GetRounding(BuildQuery()) ?? StandardRounding.ToEven. 16 | - Cache resolved strategy at context creation for performance when possible. 17 | - Provider composition: 18 | - CompositeRoundingProvider with deterministic ordering and first-match wins; allow registration in DI with named instances. 19 | - Query building: 20 | - Default query uses DefaultCurrency, IsCashTransaction, MaxScale, DateTimeOffset.UtcNow, Attributes. 21 | 22 | API sketches 23 | - See features/PossibleFeatures.md section “Rounding Strategy vs Provider, and Operators” for concrete code snippets. 24 | 25 | Risks / considerations 26 | - Keep lookups O(1) with precomputed dictionaries; avoid per-op allocations. 27 | - Ensure AOT-friendliness (no reflection). Provide sealed structs for built-ins. 28 | 29 | Open questions 30 | - Do we allow per-currency overrides directly in MoneyContextOptions in addition to providers? 31 | - Should providers be able to observe the caller’s operation (e.g., allocation vs multiplication)? 32 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneyFiveMostUsedCurrenciesSpec/CreatePonds.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.MoneyFiveMostUsedCurrenciesSpec; 2 | 3 | public class CreatePonds 4 | { 5 | [Fact] 6 | public void WhenDecimal_ThenCreatingShouldSucceed() 7 | { 8 | //from decimal (other integral types are implicitly converted to decimal) 9 | var pounds = Money.PoundSterling(10.00m); 10 | 11 | pounds.Should().NotBeNull(); 12 | pounds.Currency.Should().Be(Currency.FromCode("GBP")); 13 | pounds.Amount.Should().Be(10.00m); 14 | } 15 | 16 | [Fact] 17 | public void WhenDecimalAndRoundingAwayFromZero_ThenCreatingShouldSucceed() 18 | { 19 | //from decimal (other integral types are implicitly converted to decimal) 20 | var pounds1 = Money.PoundSterling(10.005m); 21 | var pounds2 = Money.PoundSterling(10.005m, MidpointRounding.AwayFromZero); 22 | 23 | pounds2.Currency.Should().Be(Currency.FromCode("GBP")); 24 | pounds2.Amount.Should().Be(10.01m); 25 | pounds1.Amount.Should().NotBe(pounds2.Amount); 26 | } 27 | 28 | [Fact] 29 | public void WhenDouble_ThenCreatingShouldSucceed() 30 | { 31 | //from double (float is implicitly converted to double) 32 | var pounds = Money.PoundSterling(10.005D); 33 | 34 | pounds.Should().NotBeNull(); 35 | pounds.Currency.Should().Be(Currency.FromCode("GBP")); 36 | pounds.Amount.Should().Be(10.00m); 37 | } 38 | 39 | [Fact] 40 | public void WhenLong_ThenCreatingShouldSucceed() 41 | { 42 | //from long (byte, short and int are implicitly converted to long) 43 | var pounds = Money.PoundSterling(10L); 44 | 45 | pounds.Should().NotBeNull(); 46 | pounds.Currency.Should().Be(Currency.FromCode("GBP")); 47 | pounds.Amount.Should().Be(10.00m); 48 | } 49 | 50 | [Fact] 51 | public void WhenULong_ThenCreatingShouldSucceed() 52 | { 53 | var pounds = Money.PoundSterling(10UL); 54 | 55 | pounds.Should().NotBeNull(); 56 | pounds.Currency.Should().Be(Currency.FromCode("GBP")); 57 | pounds.Amount.Should().Be(10.00m); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /features/proposals/04-exchange-rate-provider-model-and-conversion-context.md: -------------------------------------------------------------------------------- 1 | # Exchange-Rate Provider Model and Conversion Context 2 | 3 | Why it matters 4 | - Chained, metadata-rich exchange rate providers (like JavaMoney) enable deterministic selection, fallbacks, caching, and auditability. 5 | 6 | Proposal 7 | - Introduce IExchangeRateProvider, ConversionQuery, ExchangeRate, and RateContext with optional composite and cached providers. Provide a Money.ConvertTo(...) helper that respects MoneyContext rounding. 8 | 9 | Possible implementation 10 | - Core abstractions: 11 | - interface IExchangeRateProvider { string Id { get; } bool TryGetRate(in ConversionQuery query, out ExchangeRate rate); } 12 | - sealed record RateContext(string ProviderId, DateTimeOffset? AsOf = null, string? Source = null, IReadOnlyDictionary? Attributes = null) 13 | - sealed record ExchangeRate(CurrencyInfo BaseCurrency, CurrencyInfo CounterCurrency, decimal Factor, RateContext Context) 14 | - readonly record struct ConversionQuery(CurrencyInfo BaseCurrency, CurrencyInfo CounterCurrency, DateTimeOffset? At = null, MoneyContext? MoneyContext = null, IReadOnlyDictionary? Attributes = null) 15 | - Composition and caching: 16 | - CompositeExchangeRateProvider(params IExchangeRateProvider[]) prioritizes in order. 17 | - CachedExchangeRateProvider(IExchangeRateProvider inner, TimeSpan ttl) with bucketed cache key. 18 | - Optional TriangulatingProvider to route via a hub (e.g., EUR). 19 | - Conversion helper: 20 | - Money.ConvertTo(target, provider, query?) rounds using MoneyContext.RoundingStrategy for the target currency. 21 | - DI integration: 22 | - Register default provider chain in NodaMoney.DependencyInjection; support named chains per MoneyContext. 23 | 24 | API sketches 25 | - See features/proposals/exchange-rate-model-example.md for detailed code. 26 | 27 | Risks / considerations 28 | - Ensure thread-safe provider composition and caching. 29 | - Avoid double rounding; round only at final amount per context. 30 | 31 | Open questions 32 | - Should we expose provenance (ExchangeRate) alongside converted Money in APIs? 33 | - Policy for inverse rates vs explicit reverse entries? 34 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/CurrencyInfoSpec/ValidateTheDateRange.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.CurrencyInfoSpec; 2 | 3 | public class ValidateTheDateRange 4 | { 5 | [Fact] 6 | public void WhenValidatingACurrencyThatIsAlwaysActive_ThenShouldSucceed() 7 | { 8 | var currency = CurrencyInfo.FromCode("EUR"); 9 | 10 | currency.IntroducedOn.Should().BeNull(); 11 | currency.ExpiredOn.Should().BeNull(); 12 | 13 | currency.IsActiveOn(DateTime.Today).Should().BeTrue(); 14 | currency.IsHistoric.Should().BeFalse(); 15 | } 16 | 17 | [Fact] 18 | public void WhenValidatingACurrencyThatIsActiveUntilACertainDate_ThenShouldBeActiveStrictlyBeforeThatDate() 19 | { 20 | var currency = CurrencyInfo.FromCode("VEB"); 21 | 22 | currency.IntroducedOn.Should().BeNull(); 23 | currency.ExpiredOn.Should().Be(new DateTime(2008, 1, 1)); 24 | 25 | currency.IsActiveOn(DateTime.MinValue).Should().BeTrue(); 26 | currency.IsActiveOn(DateTime.MaxValue).Should().BeFalse(); 27 | currency.IsActiveOn(new DateTime(2007, 12, 31)).Should().BeTrue(); 28 | // assumes that the until date given in the wikipedia article is excluding. 29 | // assumption based on the fact that some dates are the first of the month/year 30 | // and that the euro started at 1999-01-01. Given that the until date of e.g. the Dutch guilder 31 | // is 1999-01-01, the until date must be excluding 32 | currency.IsActiveOn(new DateTime(2008, 1, 1)).Should().BeTrue("the until date is excluding"); 33 | } 34 | 35 | [Fact] 36 | public void WhenValidatingACurrencyThatIsActiveFromACertainDate_ThenShouldBeActiveFromThatDate() 37 | { 38 | var currency = CurrencyInfo.FromCode("VES"); 39 | 40 | currency.IntroducedOn.Should().Be(new DateTime(2018, 8, 20)); 41 | currency.ExpiredOn.Should().BeNull(); 42 | 43 | currency.IsActiveOn(DateTime.MinValue).Should().BeFalse(); 44 | currency.IsActiveOn(DateTime.MaxValue).Should().BeTrue(); 45 | currency.IsActiveOn(new DateTime(2018, 8, 19)).Should().BeFalse(); 46 | currency.IsActiveOn(new DateTime(2018, 8, 20)).Should().BeTrue(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/Serialization/XmlSerializationSpec/XmlSerializationHelper.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Xml; 3 | using System.Xml.Serialization; 4 | 5 | namespace NodaMoney.Tests.Serialization.XmlSerializationSpec; 6 | 7 | // old format v1 : 8 | // new format v2: 765.43. 9 | // Last option is better because it separates the currency and amount into two distinct parts: an attribute and the text content. 10 | // This can make it easier to parse programmatically, as you can directly access the currency and amount without needing to split a string. 11 | // However, it is slightly more complex in terms of XML structure. 12 | // In terms of XML best practices, attributes are generally used to provide additional information about an element, while the 13 | // text content of the element is the primary data. In this case, the amount of money could be considered the primary data, 14 | // and the currency could be considered additional information, which would suggest that option 2 is more in line with XML best practices. 15 | 16 | public class XmlSerializationHelper 17 | { 18 | public static string SerializeToXml(object source) 19 | { 20 | var settings = new XmlWriterSettings 21 | { 22 | Indent = false, 23 | OmitXmlDeclaration = true 24 | }; 25 | using var stream = new StringWriter(); 26 | using var writer = XmlWriter.Create(stream, settings); 27 | 28 | var xmlSerializer = new XmlSerializer(source.GetType()); 29 | 30 | // Suppress namespace declarations 31 | var emptyNamespaces = new XmlSerializerNamespaces(); 32 | emptyNamespaces.Add("", ""); 33 | 34 | xmlSerializer.Serialize(writer, source, emptyNamespaces); 35 | 36 | return stream.ToString(); 37 | } 38 | 39 | public static T DeserializeFromXml(string xml) 40 | { 41 | var xmlSerializer = new XmlSerializer(typeof(T)); 42 | using var stream = new StringReader(xml); 43 | return (T)xmlSerializer.Deserialize(stream); 44 | } 45 | 46 | public static T Clone(object source) => DeserializeFromXml(SerializeToXml(source)); 47 | } 48 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/CurrencyInfoSpec/CurrencyFromRegionOrCulture.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | 3 | namespace NodaMoney.Tests.CurrencyInfoSpec; 4 | 5 | public class CurrencyFromRegionOrCulture 6 | { 7 | [Fact] 8 | public void WhenUsingRegionInfo_ThenCreatingShouldSucceed() 9 | { 10 | var currency = CurrencyInfo.GetInstance(new RegionInfo("NL")); 11 | 12 | currency.Should().NotBeNull(); 13 | currency.Symbol.Should().Be("€"); 14 | currency.Code.Should().Be("EUR"); 15 | currency.EnglishName.Should().Be("Euro"); 16 | } 17 | 18 | [Fact] 19 | public void WhenRegionInfoIsNull_ThenCreatingShouldThrow() 20 | { 21 | Action action = () => CurrencyInfo.GetInstance((RegionInfo)null); 22 | 23 | action.Should().Throw(); 24 | } 25 | 26 | [Fact] 27 | public void WhenUsingCultureInfo_ThenCreatingShouldSucceed() 28 | { 29 | var currency = CurrencyInfo.GetInstance(CultureInfo.CreateSpecificCulture("nl-NL")); 30 | 31 | currency.Should().NotBeNull(); 32 | currency.Symbol.Should().Be("€"); 33 | currency.Code.Should().Be("EUR"); 34 | currency.EnglishName.Should().Be("Euro"); 35 | } 36 | 37 | [Fact] 38 | public void WhenUsingNumberFormatInfo_ThenCreatingShouldSucceed() 39 | { 40 | NumberFormatInfo nfi = CultureInfo.CreateSpecificCulture("nl-NL").NumberFormat; 41 | var currency = CurrencyInfo.GetInstance(nfi); 42 | 43 | currency.Should().NotBeNull(); 44 | currency.Symbol.Should().Be("€"); 45 | currency.Code.Should().Be("EUR"); 46 | currency.EnglishName.Should().Be("Euro"); 47 | } 48 | 49 | [Fact] 50 | public void WhenCultureInfoIsNull_ThenCreatingShouldThrow() 51 | { 52 | Action action = () => CurrencyInfo.GetInstance((IFormatProvider)null); 53 | 54 | action.Should().Throw(); 55 | } 56 | 57 | [Fact] 58 | public void WhenCultureInfoIsNeutralCulture_ThenCreatingShouldThrow() 59 | { 60 | Action action = () => CurrencyInfo.GetInstance(new CultureInfo("en")); 61 | 62 | action.Should().Throw(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/NodaMoney/InvalidCurrencyException.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | using System.Runtime.Serialization; 3 | 4 | namespace NodaMoney; 5 | 6 | /// The exception that is thrown when the is invalid for the current context or object state. 7 | [ComVisible(true)] 8 | [Serializable] 9 | public class InvalidCurrencyException : InvalidOperationException 10 | { 11 | /// Initializes a new instance of the class. 12 | public InvalidCurrencyException() : base("The requested operation cannot be performed with the specified currency!") { } 13 | 14 | /// Initializes a new instance of the class. 15 | /// The message that describes the error. 16 | public InvalidCurrencyException(string message) : base(message) { } 17 | 18 | /// Initializes a new instance of the class. 19 | /// The error message that explains the reason for the exception. 20 | /// The exception that is the cause of the current exception. If the 21 | /// parameter is not a null reference (Nothing in Visual Basic), the current exception is raised in a catch block that 22 | /// handles the inner exception. 23 | public InvalidCurrencyException(string message, Exception innerException) : base(message, innerException) { } 24 | 25 | /// Initializes a new instance of the class. 26 | /// The expected currency. 27 | /// The actual currency. 28 | public InvalidCurrencyException(Currency expected, Currency actual) 29 | : this($"Currency mismatch: The requested operation expected the currency {expected.Code}, but the actual value was the currency {actual.Code}!") { } 30 | 31 | #if !NETSTANDARD1_3 && !NET6_0_OR_GREATER 32 | /// 33 | [Obsolete("Formatter-based serialization is obsolete and not recommended for use in modern applications.", false)] 34 | protected InvalidCurrencyException(SerializationInfo info, StreamingContext context) : base(info, context) { } 35 | #endif 36 | } 37 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/FastMoneySpec/CreateFastMoneyWithMinAndMaxValues.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.FastMoneySpec; 2 | 3 | public class CreateFastMoneyWithMinAndMaxValues 4 | { 5 | [Fact] 6 | public void WhenMaxValue() 7 | { 8 | // Arrange 9 | Currency eur = CurrencyInfo.FromCode("EUR"); 10 | 11 | // Act 12 | var result = FastMoney.MinValue with { Currency = eur }; 13 | 14 | // Assert 15 | result.Amount.Should().Be(long.MinValue / 10_000L); 16 | result.Currency.Should().Be(eur); 17 | } 18 | 19 | [Fact] 20 | public void WhenMinValue() 21 | { 22 | // Arrange 23 | Currency eur = CurrencyInfo.FromCode("EUR"); 24 | 25 | // Act 26 | var result = FastMoney.MinValue with { Currency = eur }; 27 | 28 | // Assert 29 | result.Amount.Should().Be(long.MinValue / 10_000L); 30 | result.Currency.Should().Be(eur); 31 | } 32 | 33 | [Fact] 34 | public void WhenDecimalMaxValue_ThrowArgumentException() 35 | { 36 | // Arrange 37 | 38 | // Act 39 | Action action = () => new FastMoney(decimal.MaxValue, "EUR"); 40 | 41 | // Assert 42 | action.Should().Throw(); 43 | 44 | } 45 | 46 | [Fact] 47 | public void WhenDecimalMinValue_ThrowArgumentException() 48 | { 49 | // Arrange 50 | 51 | // Act 52 | Action action = () => new FastMoney(decimal.MinValue, "EUR"); 53 | 54 | // Assert 55 | action.Should().Throw(); 56 | } 57 | 58 | [Fact] 59 | public void WhenCurrencyHas4Decimals_DontThrowException() 60 | { 61 | // Arrange 62 | CurrencyInfo clf = CurrencyInfo.FromCode("CLF"); // 4 decimals 63 | 64 | // Act 65 | Action action = () => new FastMoney(1m, clf); 66 | 67 | // Assert 68 | action.Should().NotThrow(); 69 | } 70 | 71 | [Fact] 72 | public void WhenCurrencyHasMoreThan4Decimals_ThrowException() 73 | { 74 | // Arrange 75 | CurrencyInfo bitcoin = CurrencyInfo.FromCode("BTC"); // 8 decimals 76 | 77 | // Act 78 | Action action = () => new FastMoney(1m, bitcoin); 79 | 80 | // Assert 81 | action.Should().Throw(); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/CurrencySpec/CurrencyFromIsoCode.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.CurrencySpec; 2 | 3 | public class CurrencyFromIsoCode 4 | { 5 | [Fact] 6 | public void WhenIsoCodeIsExisting_ThenCreatingShouldSucceed() 7 | { 8 | // Arrange / Act 9 | Currency currency = Currency.FromCode("EUR"); 10 | 11 | // Assert 12 | currency.Should().NotBeNull(); 13 | currency.Code.Should().Be("EUR"); 14 | currency.Symbol.Should().Be("€"); 15 | currency.IsIso4217.Should().BeTrue(); 16 | } 17 | 18 | [Fact] 19 | public void WhenNonIsoCode_NonIsoCurrencyIsCreated() 20 | { 21 | // Arrange / Act 22 | Currency currency = Currency.FromCode("BTC"); 23 | 24 | // Assert 25 | currency.Should().NotBeNull(); 26 | currency.Code.Should().Be("BTC"); 27 | currency.Symbol.Should().Be("₿"); 28 | currency.IsIso4217.Should().BeFalse(); 29 | } 30 | 31 | [Fact] 32 | public void WhenIsoCodeIsUnknown_ThenCreatingShouldThrow() 33 | { 34 | Action action = () => Currency.FromCode("AAA"); 35 | 36 | action.Should().Throw(); 37 | } 38 | 39 | [Fact] 40 | public void WhenIsoCodeIsNull_ThenCreatingShouldThrow() 41 | { 42 | Action action = () => Currency.FromCode(null); 43 | 44 | action.Should().Throw(); 45 | } 46 | 47 | [Fact] 48 | public void WhenCreatingWithIsoCodeFromCurrencyInfo_ThenCreatingShouldSucceed() 49 | { 50 | // Arrange / Act 51 | Currency currency = CurrencyInfo.FromCode("EUR"); 52 | 53 | // Assert 54 | currency.Should().NotBeNull(); 55 | currency.Code.Should().Be("EUR"); 56 | currency.Symbol.Should().Be("€"); 57 | currency.IsIso4217.Should().BeTrue(); 58 | } 59 | 60 | [Fact] 61 | public void WhenWithNonIsoCodeFromCurrencyInfo_NonIsoCurrencyIsCreated() 62 | { 63 | // Arrange / Act 64 | CurrencyInfo currency1 = CurrencyInfo.FromCode("BTC"); 65 | Currency currency2 = currency1; 66 | Currency currency = CurrencyInfo.FromCode("BTC"); 67 | 68 | // Assert 69 | currency.Should().NotBeNull(); 70 | currency.Code.Should().Be("BTC"); 71 | currency.Symbol.Should().Be("₿"); 72 | currency.IsIso4217.Should().BeFalse(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/NodaMoney.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0;net9.0;net8.0;net48;net6.0 5 | latest 6 | enable 7 | 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/Serialization/BinaryFormatterSpec/SerializeMoney.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Runtime.Serialization; 3 | using System.Runtime.Serialization.Formatters.Binary; 4 | 5 | namespace NodaMoney.Tests.Serialization.BinaryFormatterSpec; 6 | 7 | public class SerializeMoney 8 | { 9 | private Money yen = new Money(765m, Currency.FromCode("JPY")); 10 | private Money euro = new Money(765.43m, Currency.FromCode("EUR")); 11 | 12 | #if NET8_0_OR_GREATER 13 | [Fact(Skip = "Not supported in .NET 8.0 or greater. See https://aka.ms/binaryformatter")] 14 | #else 15 | [Fact] 16 | #endif 17 | public void WhenSerializingYen_ThenThisShouldSucceed() 18 | { 19 | yen.Should().Be(Clone(yen)); 20 | } 21 | 22 | #if NET8_0_OR_GREATER 23 | [Fact(Skip = "Not supported in .NET 8.0 or greater. See https://aka.ms/binaryformatter")] 24 | #else 25 | [Fact] 26 | #endif 27 | public void WhenSerializingEuro_ThenThisShouldSucceed() 28 | { 29 | euro.Should().Be(Clone(euro)); 30 | } 31 | 32 | #if NET8_0_OR_GREATER 33 | [Fact(Skip = "Not supported in .NET 8.0 or greater. See https://aka.ms/binaryformatter")] 34 | #else 35 | [Fact] 36 | #endif 37 | public void WhenSerializingArticle_ThenThisShouldSucceed() 38 | { 39 | var article = new Order 40 | { 41 | Id = 123, 42 | Total = Money.Euro(27.15), 43 | Name = "Foo" 44 | }; 45 | 46 | article.Total.Should().Be(Clone(article).Total); 47 | } 48 | 49 | [Obsolete("Obsolete")] 50 | public static Stream Serialize(object source) 51 | { 52 | IFormatter formatter = new BinaryFormatter(); 53 | Stream stream = new MemoryStream(); 54 | formatter.Serialize(stream, source); 55 | return stream; 56 | } 57 | 58 | [Obsolete("Obsolete")] 59 | public static T Deserialize(Stream stream) 60 | { 61 | IFormatter formatter = new BinaryFormatter(); 62 | stream.Position = 0L; 63 | return (T)formatter.Deserialize(stream); 64 | } 65 | 66 | public static T Clone(object source) 67 | { 68 | // Console.WriteLine(Serialize(source).ToString()); 69 | #pragma warning disable CS0618 // Type or member is obsolete 70 | return Deserialize(Serialize(source)); 71 | #pragma warning restore CS0618 // Type or member is obsolete 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | Contributions are essential for keeping NodaMoney great. It's impossible for us to be experts in every currency in each 3 | country, so every help is appreciated. If you have a problem, a great idea or found a possible bug in NodaMoney, please 4 | report this to us as an [Issue](https://github.com/remyvd/NodaMoney/issues). 5 | 6 | Do you want to contribute with code changes? There are a few guidelines that we need contributors to follow so that your 7 | change can be accepted quickly. 8 | 9 | ## Getting Started 10 | * Make sure you have a [GitHub account](https://github.com/signup/free) 11 | * Fork the repository on GitHub (we use the [fork & pull model](https://help.github.com/articles/using-pull-requests)) 12 | * We use the [GitHub Flow Workflow](https://guides.github.com/introduction/flow/) as our branching strategy. 13 | 14 | ## Making Changes 15 | * If possible, create an issue for big improvements or features so that people can discuss. 16 | * Make commits of logical units so that we can pick and choose. 17 | * Make sure your commit messages have a good description. 18 | * Make sure you have added the necessary tests for your changes. 19 | * Make sure there are no warnings when building. 20 | * Run _all_ the tests to ensure nothing else was accidentally broken. 21 | * Push your changes to your fork of the repository. 22 | * Submit a [pull request](https://help.github.com/articles/creating-a-pull-request/) to the NodaMoney repository. 23 | * If needed, we will give feedback about the pull request or accept it right away. 24 | 25 | ## Coding guidelines 26 | There are enough guidelines on the web, so to create or own would be a waste less undertaking and only done for our own ego. 27 | 28 | The [Microsoft Framework Design Guidelines](https://learn.microsoft.com/en-us/dotnet/standard/design-guidelines/?redirectedfrom=MSDN) 29 | is a fairly thorough document of how to write managed code and should be used as a starting point. 30 | 31 | The [C# Coding Guidelines](https://csharpcodingguidelines.com/) is a good additional document that focuses on the 32 | C# language and its best practices. 33 | 34 | [Code quality rules](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/) are being [analyzed automatically](https://learn.microsoft.com/en-us/visualstudio/code-quality/roslyn-analyzers-overview) 35 | as part of the build. We also added [Roslyn Analyzers](https://github.com/dotnet/roslyn-analyzers) to enforce an additional set of style and consistency rules. 36 | -------------------------------------------------------------------------------- /features/cldr/feature-parsing.md: -------------------------------------------------------------------------------- 1 | # Feature Proposal: Parsing Behavior (CLDR-aligned) 2 | 3 | Status: proposal/spec 4 | Source: docs/feature-cldr-formatting-and-parsing.md 5 | 6 | ## Objective 7 | Parse locale-formatted currency strings into Money with robust handling of symbols, codes, spacing, grouping, and accounting parentheses per CLDR. 8 | 9 | ## Key Behaviors 10 | - Locale-aware tokenization: recognize decimal/group symbols (including NBSP, narrow NBSP), plus/minus, parentheses. 11 | - Currency resolution: longest-match policy across per-locale tries of tokens: symbol, narrow symbol, and ISO code; fallback to locale defaults for ambiguous symbols (e.g., $). 12 | - Sign handling: detect accounting (parentheses) and explicit minus; support SignDisplay rules where relevant. 13 | - Grouping validation: lenient by default; optionally strict in future options. 14 | - Fraction digits: accept inputs with varying fraction digits up to a reasonable maximum; rounding handled by MoneyContext afterward if needed. 15 | 16 | ## Possible Implementations 17 | 18 | ### Implementation 1 — Table-driven tokenizer + tries 19 | - Prebuild per-locale token tries (from generated data) for symbol/narrow/ISO. 20 | - Scan the input with a small DFA, classifying whitespace, digits, separators, and currency tokens. 21 | - Pros: Fast and deterministic; robust to multiple whitespace kinds. 22 | - Cons: More code; must maintain per-locale token tables. 23 | 24 | ### Implementation 2 — Regex-assisted parsing (limited) 25 | - Use compiled regex per-locale to split currency token and numeric part; post-process per rules. 26 | - Pros: Rapid prototype. 27 | - Cons: Harder to keep AOT-friendly and allocation-lean; brittle for edge cases. 28 | 29 | ## Ambiguity and Policies 30 | - When symbol is ambiguous, prefer the currency associated with the locale (e.g., en-CA → CAD). Expose override hooks in MoneyFormatOptions (future) or ambient MoneyContext default currency. 31 | - If currency is missing and amount is zero, respect EnforceZeroCurrencyMatching policy from MoneyContext. 32 | 33 | ## Error Handling 34 | - TryParse returns false for unrecognized tokens or invalid number formats. 35 | - Provide best-effort diagnostics internally for test assertions (not public API). 36 | 37 | ## Acceptance Criteria 38 | - Round-trip: values formatted by CLDR formatter parse back to identical Money for tested locales. 39 | - Golden parsing tests: real-world variants (with/without spaces, narrow NBSP, symbol vs code) succeed. 40 | - Deterministic results across platforms. 41 | -------------------------------------------------------------------------------- /src/NodaMoney/MoneyContextMismatchException.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | using System.Runtime.Serialization; 3 | using NodaMoney.Context; 4 | 5 | namespace NodaMoney; 6 | 7 | /// The exception that is thrown when the is invalid for the current context or object state. 8 | [ComVisible(true)] 9 | [Serializable] 10 | public class MoneyContextMismatchException : InvalidOperationException 11 | { 12 | /// Initializes a new instance of the class. 13 | public MoneyContextMismatchException() : base("The requested operation cannot be performed with the specified MoneyContext!") { } 14 | 15 | /// Initializes a new instance of the class. 16 | /// The message that describes the error. 17 | public MoneyContextMismatchException(string message) : base(message) { } 18 | 19 | /// Initializes a new instance of the class. 20 | /// The error message that explains the reason for the exception. 21 | /// The exception that is the cause of the current exception. If the 22 | /// parameter is not a null reference (Nothing in Visual Basic), the current exception is raised in a catch block that 23 | /// handles the inner exception. 24 | public MoneyContextMismatchException(string message, Exception innerException) : base(message, innerException) { } 25 | 26 | /// Initializes a new instance of the class. 27 | /// The expected currency. 28 | /// The actual currency. 29 | public MoneyContextMismatchException(MoneyContext expected, MoneyContext actual) 30 | : this($"MoneyContext mismatch: The requested operation expected {expected}, but the actual value was {actual}! Use the 'with' expression to align contexts before operation, e.g.: money2 with {{ Context = money1.Context }}") { } 31 | 32 | #if !NETSTANDARD1_3 && !NET6_0_OR_GREATER 33 | /// 34 | [Obsolete("Formatter-based serialization is obsolete and not recommended for use in modern applications.", false)] 35 | protected MoneyContextMismatchException(SerializationInfo info, StreamingContext context) : base(info, context) { } 36 | #endif 37 | } 38 | -------------------------------------------------------------------------------- /tests/Benchmark/BenchmarkDotNet.Artifacts/results/Benchmark.MoneyOperationsBenchmarks-report-github.md: -------------------------------------------------------------------------------- 1 | ``` 2 | 3 | BenchmarkDotNet v0.15.6, Windows 11 (10.0.26200.7171) 4 | AMD Ryzen 7 5800H with Radeon Graphics 3.20GHz, 1 CPU, 16 logical and 8 physical cores 5 | .NET SDK 10.0.100 6 | [Host] : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3 7 | DefaultJob : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3 8 | 9 | 10 | ``` 11 | | Method | Mean | Error | Op/s | Ratio | Allocated | Alloc Ratio | 12 | |------------------------ |----------:|----------:|--------------:|------:|----------:|------------:| 13 | | Add | 28.195 ns | 0.5836 ns | 35,466,906.4 | 14.90 | - | NA | 14 | | Subtract | 30.374 ns | 0.3741 ns | 32,922,933.9 | 16.05 | - | NA | 15 | | Multiple | 29.046 ns | 0.3349 ns | 34,427,988.1 | 15.35 | - | NA | 16 | | Divide | 70.748 ns | 0.9017 ns | 14,134,735.2 | 37.39 | - | NA | 17 | | Increment | 9.157 ns | 0.1865 ns | 109,206,346.3 | 4.84 | - | NA | 18 | | Decrement | 9.338 ns | 0.2062 ns | 107,087,451.5 | 4.94 | - | NA | 19 | | Remainder | 20.345 ns | 0.1780 ns | 49,152,774.0 | 10.75 | - | NA | 20 | | fAdd | 1.893 ns | 0.0566 ns | 528,159,297.6 | 1.00 | - | NA | 21 | | fSubtract | 1.737 ns | 0.0504 ns | 575,731,435.0 | 0.92 | - | NA | 22 | | fMultipleDec | 29.450 ns | 1.6689 ns | 33,955,903.5 | 15.57 | - | NA | 23 | | fMultipleDecWholeNumber | 9.521 ns | 0.1498 ns | 105,031,040.1 | 5.03 | - | NA | 24 | | fMultipleLong | 2.131 ns | 0.0690 ns | 469,277,233.0 | 1.13 | - | NA | 25 | | fDivideDec | 70.444 ns | 1.1502 ns | 14,195,762.2 | 37.23 | - | NA | 26 | | fDivideDecWholeNumber | 10.886 ns | 0.2397 ns | 91,858,580.4 | 5.75 | - | NA | 27 | | fDivideLong | 2.024 ns | 0.0540 ns | 493,960,294.3 | 1.07 | - | NA | 28 | | fIncrement | 5.215 ns | 0.1268 ns | 191,744,815.9 | 2.76 | - | NA | 29 | | fDecrement | 4.627 ns | 0.1185 ns | 216,144,432.2 | 2.45 | - | NA | 30 | | fRemainder | 1.935 ns | 0.0278 ns | 516,849,107.9 | 1.02 | - | NA | 31 | -------------------------------------------------------------------------------- /src/NodaMoney/Currency.Serializable.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.Serialization; 4 | using System.Text.Json.Serialization; 5 | using System.Xml; 6 | using System.Xml.Schema; 7 | using System.Xml.Serialization; 8 | using NodaMoney.Serialization; 9 | 10 | namespace NodaMoney; 11 | 12 | [Serializable] 13 | [TypeConverter(typeof(CurrencyTypeConverter))] // Used by Newtonsoft.Json to do the serialization. 14 | [JsonConverter(typeof(CurrencyJsonConverter))] // Used by System.Text.Json to do the serialization. 15 | // IXmlSerializable used for XML serialization (ReadXml, WriteXml, GetSchema), 16 | // ISerializable for binary serialization (GetObjectData, ctor(SerializationInfo, StreamingContext)) 17 | public readonly partial record struct Currency : IXmlSerializable, ISerializable 18 | { 19 | /// 20 | public XmlSchema? GetSchema() => null; 21 | 22 | /// 23 | public void ReadXml(XmlReader reader) 24 | { 25 | if (reader == null) 26 | throw new ArgumentNullException(nameof(reader)); 27 | 28 | var currency = reader.GetAttribute("Currency"); 29 | if (currency is not null) 30 | { 31 | // v1 format: 32 | string[] v = currency.Split([';']); 33 | if (v.Length == 1 || string.IsNullOrWhiteSpace(v[1]) || v[1] == "ISO-4217") 34 | { 35 | Unsafe.AsRef(in this) = new Currency(v[0]); 36 | } 37 | else // Only if the 2nd part is not empty and not "ISO-4217", it is a custom currency 38 | { 39 | Unsafe.AsRef(in this) = new Currency(v[0]) { IsIso4217 = false }; 40 | } 41 | } 42 | } 43 | 44 | /// 45 | public void WriteXml(XmlWriter writer) 46 | { 47 | if (writer == null) 48 | throw new ArgumentNullException(nameof(writer)); 49 | 50 | writer.WriteAttributeString("Currency", IsIso4217 ? Code : $"{Code};CUSTOM"); 51 | } 52 | 53 | /// 54 | public void GetObjectData(SerializationInfo info, StreamingContext context) 55 | { 56 | if (info == null) 57 | throw new ArgumentNullException(nameof(info)); 58 | 59 | info.AddValue("code", Code); 60 | info.AddValue("isIso4217", IsIso4217); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /features/cldr/feature-data-generation.md: -------------------------------------------------------------------------------- 1 | # Feature Proposal: CLDR Data Generation & Packaging 2 | 3 | Status: proposal/spec 4 | Source: docs/feature-cldr-formatting-and-parsing.md 5 | 6 | ## Objective 7 | Provide a compact, deterministic CLDR subset tailored for Money formatting/parsing, compiled into AOT/trimming-friendly assets consumed by the CLDR provider. 8 | 9 | ## Scope of Data 10 | - CurrencyDigits per ISO code: standard/cash fraction digits and cash rounding quantum. 11 | - Locale currency formatting data: default numbering system, number symbols (decimal, group, plus, minus), currency patterns (standard/accounting), currencySpacing rules. 12 | - Currency display per locale: symbol, narrow symbol, (optionally) localized name. 13 | 14 | ## Inputs (CLDR JSON) 15 | - cldr-core/supplemental/currencyData.json 16 | - cldr-numbers-full/main/{locale}/numbers.json 17 | - cldr-localenames-full/main/{locale}/currencies.json 18 | 19 | ## Generation Tool 20 | - Project: tools/NodaMoney.CldrGen (console app) 21 | - Responsibilities: 22 | 1) Resolve CLDR version and locale list; download or read local zips. 23 | 2) Normalize locales (BCP-47) and choose defaultNumberingSystem. 24 | 3) Extract required nodes into intermediate DTOs. 25 | 4) Compact strings (global string table), assign ids for locales/currencies. 26 | 5) Emit runtime artifacts (see packaging options). 27 | 28 | ## Packaging Options (Possible Implementations) 29 | 30 | ### Option 1 — Source-generated C# tables (default) 31 | - Emit readonly arrays and FrozenDictionary indexes under src/NodaMoney.Globalization.Cldr/Generated. 32 | - Pros: No runtime IO; great cold-start; very AOT-friendly. 33 | - Cons: Slightly larger IL than binary. 34 | 35 | ### Option 2 — Compact binary resource + tiny loader 36 | - Emit Resources/CldrData.bin and a generated CldrLoader.g.cs. 37 | - Pros: Small IL; data can be refreshed without regenerating large code tables. 38 | - Cons: More loader complexity. 39 | 40 | ## AOT/Trimming 41 | - Prefer value types and sealed types; avoid reflection. 42 | - Use FrozenDictionary on net8+/net9+/net10 with conditional fallbacks. 43 | - Generate parent-locale fallback map statically. 44 | 45 | ## Versioning 46 | - Pin a CLDR version (e.g., 46) and embed it into generated code (CldrInfo.Version). 47 | - Document update cadence; provide a generator command to refresh. 48 | 49 | ## Acceptance Criteria 50 | - Generator produces deterministic output from the same CLDR inputs. 51 | - Runtime provider loads data without allocations beyond necessary strings. 52 | - Works across TFMs listed in repo guidelines. 53 | -------------------------------------------------------------------------------- /.github/workflows/benchmark.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Benchmark 3 | 4 | on: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | benchmark: 9 | permissions: 10 | contents: read 11 | runs-on: ubuntu-24.04 12 | timeout-minutes: 360 # Default: 360 minutes 13 | steps: 14 | - name: 📥 Checkout 15 | uses: actions/checkout@v5 16 | with: 17 | fetch-depth: 0 18 | filter: tree:0 19 | 20 | - name: 🛠️ Setup .NET 21 | uses: actions/setup-dotnet@v5 22 | with: 23 | dotnet-version: 10.0.x 24 | cache: true 25 | cache-dependency-path: '**/packages.lock.json' 26 | env: 27 | NUGET_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} 28 | 29 | - name: 💾 Restore dependencies 30 | run: dotnet restore 31 | 32 | - name: ⚙️ Build 33 | run: dotnet build -c Release --no-restore 34 | 35 | - name: 🚦 Run Benchmarks 36 | working-directory: tests/Benchmark 37 | run: dotnet run -c Release --framework net10.0 --no-build --no-launch-profile 38 | 39 | - name: Upload artifacts 40 | uses: actions/upload-artifact@v5 41 | with: 42 | name: BenchmarkDotNet.Artifacts 43 | path: tests/Benchmark/BenchmarkDotNet.Artifacts/ 44 | if-no-files-found: error 45 | 46 | - name: Output results to JobSummary 47 | working-directory: tests/Benchmark 48 | shell: pwsh 49 | run: | 50 | $items = Get-ChildItem "BenchmarkDotNet.Artifacts/results/*.md" | where Name -ne 'reports.md' 51 | foreach($item in $items) { 52 | $title = $item.Name 53 | $title = $title.Replace('-report-console.md', '') 54 | $title = $title.Replace('_Int32_' , '<int>') 55 | $title = $title.Replace('_String_', '<string>') 56 | $title = $title.Replace('_Double_', '<double>') 57 | 58 | $content = Get-Content $item.FullName -Raw 59 | $tableContent = $content.Substring($content.IndexOf('|')) 60 | $tableContent.Replace('<', '<').Replace('>', '>') 61 | 62 | Write-Output ('## {0}' -f $title) >> $env:GITHUB_STEP_SUMMARY 63 | Write-Output $tableContent >> $env:GITHUB_STEP_SUMMARY 64 | } 65 | 66 | # https://github.com/benchmark-action/github-action-benchmark?tab=readme-ov-file 67 | - name: Store benchmark result 68 | uses: benchmark-action/github-action-benchmark@v1 69 | with: 70 | tool: 'benchmarkdotnet' 71 | output-file-path: BenchmarkDotNet.Artifacts/results/Sample.Benchmarks-report-full-compressed.json 72 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneyParsableSpec/ParseNegativeMoney.cs: -------------------------------------------------------------------------------- 1 | using NodaMoney.Tests.Helpers; 2 | 3 | namespace NodaMoney.Tests.MoneyParsableSpec; 4 | 5 | [Collection(nameof(NoParallelization))] 6 | public class ParseNegativeMoney 7 | { 8 | [Fact, UseCulture("en-US")] 9 | public void WhenMinusSignBeforeDollarSign_ThenThisShouldSucceed() 10 | { 11 | string value = "-$98,765.43"; 12 | var dollar = Money.Parse(value); 13 | 14 | dollar.Should().Be(new Money(-98_765.43, "USD")); 15 | } 16 | 17 | [Fact, UseCulture("en-US")] 18 | public void WhenMinusSignAfterDollarSign_ThenThisShouldSucceed() 19 | { 20 | string value = "$-98,765.43"; 21 | var dollar = Money.Parse(value); 22 | 23 | dollar.Should().Be(new Money(-98_765.43, "USD")); 24 | } 25 | 26 | [Fact, UseCulture("en-US")] 27 | public void WhenDollarsWithParentheses_ThenThisShouldSucceed() 28 | { 29 | string value = "($98,765.43)"; 30 | var dollar = Money.Parse(value); 31 | 32 | dollar.Should().Be(new Money(-98_765.43, "USD")); 33 | } 34 | 35 | [Fact, UseCulture("nl-NL")] 36 | public void WhenMinusSignBeforeEuroSign_ThenThisShouldSucceed() 37 | { 38 | string value = "-€ 98.765,43"; 39 | var dollar = Money.Parse(value); 40 | 41 | dollar.Should().Be(new Money(-98_765.43, "EUR")); 42 | } 43 | 44 | [Fact, UseCulture("nl-NL")] 45 | public void WhenMinusSignAfterEuroSign_ThenThisShouldSucceed() 46 | { 47 | string value = "€ -98.765,43"; 48 | var dollar = Money.Parse(value); 49 | 50 | dollar.Should().Be(new Money(-98_765.43, "EUR")); 51 | } 52 | 53 | [Fact, UseCulture("nl-NL")] 54 | public void WhenEurosWithParentheses_ThenThisShouldSucceed() 55 | { 56 | string value = "(€ 98.765,43)"; 57 | var dollar = Money.Parse(value); 58 | 59 | dollar.Should().Be(new Money(-98_765.43, "EUR")); 60 | } 61 | 62 | [Fact, UseCulture("de-CH")] 63 | public void WhenMinusSignBeforeChfSign_ThenThisShouldSucceed() 64 | { 65 | var money = Money.Parse("-CHF 98’765.43"); 66 | 67 | money.Should().Be(new Money(-98765.43, "CHF")); 68 | } 69 | 70 | [Fact, UseCulture("de-CH")] 71 | public void WhenMinusSignAfterCHFSign_ThenThisShouldSucceed() 72 | { 73 | var money = Money.Parse("CHF -98’765.43"); 74 | 75 | money.Should().Be(new Money(-98765.43, "CHF")); 76 | } 77 | 78 | [Fact, UseCulture("de-CH")] 79 | public void WhenChfWithParentheses_ThenThisShouldSucceed() 80 | { 81 | var money = Money.Parse("(CHF 98’765.43)"); 82 | 83 | money.Should().Be(new Money(-98765.43, "CHF")); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/NodaMoney/Price.cs: -------------------------------------------------------------------------------- 1 | #if NET7_0_OR_GREATER 2 | using System.Numerics; 3 | 4 | namespace NodaMoney; 5 | 6 | // I think the difference lies where the money type is being used for: 7 | // (Total) Amount or (Unit) Price. 8 | // 9 | // A Price is money/unit, which, if multiplied by the unit result into money (=(Total) Amount). So: 10 | // unit * price = amount 11 | // unit * money/unit = money 12 | // 13 | // (Total) Amounts should I think always be rounded to prevent errors, but I understand that sometimes (Unit) Prices 14 | // needs more precision. Maybe adding a type Price which allows more precision could help? 15 | // 16 | // The example you give is a special type of price, an hour rate: money/hour or money/TimeSpan. This could be 17 | // new Price(0.0034m, "EUR") or a type HourRate that extends from Price. 18 | // 19 | // Hour rate could also have business rules about rounding.You could do for example: 20 | // TimeSpan * HourRate = (Total) Amount 21 | // 22 | // 23 | // ### **Recommendation:** 24 | // The choice depends on your priorities for **clarity** and **domain alignment**: 25 | // 1. **General financial clarity** → Use **`Rate`**. (or MonetaryRate) 26 | // - Works well for generic cases or if you want flexibility to expand usage to rates beyond price alone (e.g., exchange rates, fee rates). 27 | // - Gives your type room to grow without feeling too application-specific. 28 | // 29 | // 2. **Specificity and common business terminology** → Use **`Price`**. (or UnitPrice) 30 | // - If this type is primarily meant to represent **money/unit transaction costs**, `Price` is simple, intuitive, and aligns with user expectations. 31 | // - "Price" implies rounding in some cases: Many users might associate "price" with rounded monetary values (e.g., price tags or displayed 32 | // transaction amounts). Since your `Price` type specifically allows **unrounded decimals** to preserve precision, the term might be slightly misleading. 33 | // 34 | // 3. **Precision and clarity** → Use **`UnitPrice`** or **`PricePerUnit`**. 35 | // - These work if you want to explicitly distinguish between total amounts and per-unit prices. 36 | // 37 | // If you're planning to make strict distinctions between `Price` and `Rate` types (e.g., `Rate` for fees and taxes, `Price` for fixed costs like products or services), I suggest reserving **`Price` for fixed money/unit relationships** and using **`Rate` for more general financial ratios.** This aligns better with domain conventions. 38 | 39 | internal readonly struct Price(decimal ratio, Currency currency) where T : IMultiplyOperators 40 | { 41 | public Money Multiple(T units) 42 | { 43 | var result = units * ratio; 44 | return new Money(result, currency); 45 | } 46 | } 47 | #endif 48 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/ExchangeRateSpec/CreateAnExchangeRateWithCurrenciesAsStrings.cs: -------------------------------------------------------------------------------- 1 | using NodaMoney.Exchange; 2 | 3 | namespace NodaMoney.Tests.ExchangeRateSpec; 4 | 5 | public class CreateAnExchangeRateWithCurrenciesAsStrings 6 | { 7 | private readonly string _euroAsString = "EUR"; 8 | 9 | private readonly string _dollarAsString = "USD"; 10 | 11 | private readonly Currency _euro = Currency.FromCode("EUR"); 12 | 13 | private readonly Currency _dollar = Currency.FromCode("USD"); 14 | 15 | [Fact] 16 | public void WhenRateIsDoubleAndNoNumberRoundingDecimalsIsGiven_ThenCreatingShouldSucceedWithValueRoundedToSixDecimals() 17 | { 18 | var fx = new ExchangeRate(_euroAsString, _dollarAsString, 1.2591478D); 19 | 20 | fx.BaseCurrency.Should().Be(_euro); 21 | fx.QuoteCurrency.Should().Be(_dollar); 22 | fx.Value.Should().Be(1.259148M); 23 | } 24 | 25 | [Fact] 26 | public void WhenRateIsDoubleAndANumberOfRoundingDecimalsIsGiven_ThenCreatingShouldSucceedWithValueRoundedThatNumberOfDecimals() 27 | { 28 | var fx = new ExchangeRate(_euroAsString, _dollarAsString, 1.2591478D, 3); 29 | 30 | fx.BaseCurrency.Should().Be(_euro); 31 | fx.QuoteCurrency.Should().Be(_dollar); 32 | fx.Value.Should().Be(1.259M); 33 | } 34 | 35 | [Fact] 36 | public void WhenRateIsDecimal_ThenCreatingShouldSucceed() 37 | { 38 | var fx = new ExchangeRate(_euroAsString, _dollarAsString, 1.2591M); 39 | 40 | fx.BaseCurrency.Should().Be(_euro); 41 | fx.QuoteCurrency.Should().Be(_dollar); 42 | fx.Value.Should().Be(1.2591M); 43 | } 44 | 45 | [Fact] 46 | public void WhenRateIsFloatAndNoNumberOfRoundingDecimalsIsGiven_ThenCreatingShouldSucceedWithValueRoundedToSixDecimals() 47 | { 48 | var fx = new ExchangeRate(_euroAsString, _dollarAsString, 1.2591478F); 49 | 50 | fx.BaseCurrency.Should().Be(_euro); 51 | fx.QuoteCurrency.Should().Be(_dollar); 52 | fx.Value.Should().Be(1.259148M); 53 | } 54 | 55 | [Fact] 56 | public void WhenRateIsFloatAndANumberOfRoundingDecimalsIsGiven_ThenCreatingShouldSucceedWithValueRoundedThatNumberOfDecimals() 57 | { 58 | var fx = new ExchangeRate(_euroAsString, _dollarAsString, 1.2591478F, 3); 59 | 60 | fx.BaseCurrency.Should().Be(_euro); 61 | fx.QuoteCurrency.Should().Be(_dollar); 62 | fx.Value.Should().Be(1.259M); 63 | } 64 | 65 | [Fact] 66 | public void WhenBaseAndQuoteCurrencyAreTheSame_ThenCreatingShouldThrow() 67 | { 68 | // Arrange, Act 69 | Action action = () => new ExchangeRate(_euroAsString, _euroAsString, 1.2591F); 70 | 71 | // Assert 72 | action.Should().Throw(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/CurrencySpec/CreateInternalCurrency.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.CurrencySpec; 2 | 3 | public class CreateInternalCurrency 4 | { 5 | [Theory] 6 | [InlineData("EUR")] 7 | [InlineData("MYR")] 8 | [InlineData("USD")] 9 | public void WhenIsoCodeIsThreeCapitalLetters_IsoCurrencyIsCreated(string code) 10 | { 11 | // Arrange / Act 12 | var currency = new Currency(code); 13 | 14 | // Assert 15 | currency.Code.Should().Be(code); 16 | currency.IsIso4217.Should().BeTrue(); 17 | } 18 | 19 | [Theory] 20 | [InlineData("E")] 21 | [InlineData("EU")] 22 | [InlineData("EURO")] 23 | [InlineData("eur")] 24 | [InlineData("EU1")] 25 | public void WhenCodeIsNotThreeCapitalLetters_ThrowArgumentException(string code) 26 | { 27 | Action act = () => new Currency(code); 28 | 29 | act.Should().Throw().WithMessage("Currency code should only exist out of three capital letters*"); 30 | } 31 | 32 | [Fact] 33 | public void WhenCodeIsNull_ThrowArgumentNullException() 34 | { 35 | Action act = () => new Currency((string)null); 36 | 37 | act.Should().Throw().WithMessage("Value cannot be null*"); 38 | } 39 | 40 | [Fact] 41 | public void WhenDefaultCurrency_CurrencyIsXXX() 42 | { 43 | // Arrange / Act 44 | var noCurrency = new Currency("XXX"); 45 | Currency defaultCurrency = default; 46 | 47 | // Assert 48 | noCurrency.Should().NotBeNull(); 49 | noCurrency.Should().Be(default(Currency)); 50 | noCurrency.Code.Should().Be("XXX"); 51 | noCurrency.IsIso4217.Should().BeTrue(); 52 | defaultCurrency.Should().NotBeNull(); 53 | defaultCurrency.Should().Be(default(Currency)); 54 | defaultCurrency.Code.Should().Be("XXX"); 55 | defaultCurrency.IsIso4217.Should().BeTrue(); 56 | 57 | // Assert with XUnit methods, because https://stackoverflow.com/questions/61556309/fluent-assertions-be-vs-equals 58 | Assert.Equal(default, noCurrency); 59 | Assert.Equal(default(Currency), (object)noCurrency); 60 | Assert.True(noCurrency == default); 61 | Assert.True(noCurrency == default(Currency)); 62 | Assert.True(noCurrency.Equals(default)); 63 | Assert.True(noCurrency.Equals((object)default(Currency))); 64 | Assert.True(object.Equals(noCurrency, default(Currency))); 65 | Assert.Equal(defaultCurrency, noCurrency); 66 | Assert.Equal(Currency.NoCurrency, noCurrency); 67 | } 68 | 69 | [Fact] 70 | public void WhenCurrencyType_SizeIs2Bytes() 71 | { 72 | int size = System.Runtime.InteropServices.Marshal.SizeOf(typeof(Currency)); 73 | 74 | size.Should().Be(2); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /features/cldr/feature-formatting.md: -------------------------------------------------------------------------------- 1 | # Feature Proposal: Formatting Behavior (CLDR-aligned) 2 | 3 | Status: proposal/spec 4 | Source: docs/feature-cldr-formatting-and-parsing.md 5 | 6 | ## Objective 7 | Produce locale-accurate currency strings aligned with CLDR patterns, spacing rules, sign/accounting behavior, and fraction digit policies. 8 | 9 | ## Key Behaviors 10 | - Pattern selection: Use standard vs accounting patterns based on SignDisplay and numeric sign. 11 | - Fraction digits: Use CLDR digits (cash vs standard) unless overridden by options (Minimum/MaximumFractionDigits). Optionally apply cash rounding quantum via MoneyContext. 12 | - Number formatting: Honor grouping and decimal symbols for the locale (including Indian 3;2;2 pattern), minimize allocations via span APIs. 13 | - Currency display: Replace ¤ in the pattern with symbol/narrow/code/name per options and locale mappings. 14 | - Currency spacing: Apply before/after rules to insert NBSP/narrow NBSP where digit and currency symbol meet. 15 | - Sign display: Auto/Always/Never/ExceptZero; Accounting uses parentheses where pattern provides a negative subpattern. 16 | 17 | ## Possible Implementations 18 | 19 | ### Implementation 1 — Splice CLDR currency layer on top of .NET number formatting 20 | - Format the numeric part using decimal.TryFormat with a NumberFormatInfo configured for the locale’s digits/grouping. 21 | - Inject currency token at ¤ positions and apply spacing rules. 22 | - Pros: Leverages fast .NET base formatting; simpler; reliable across TFMs. 23 | - Cons: Must manage grouping patterns not directly supported in some locales; still need custom spacing/sign logic. 24 | 25 | ### Implementation 2 — Full custom number formatting for currencies 26 | - Implement grouping and decimal insertion via span algorithms; supports Indian grouping and custom patterns precisely. 27 | - Pros: Maximum control and consistency. 28 | - Cons: More code; higher maintenance risk; ensure perf is competitive. 29 | 30 | ## Performance Considerations 31 | - Cache compiled patterns by (culture, CurrencyDisplay, SignDisplay, cash flag) using FrozenDictionary or ConditionalWeakTable. 32 | - Avoid string concatenations; use stackalloc and value-type builders where safe. 33 | - Reuse common strings (NBSP, narrow NBSP, symbols) via interning tables from the data generator. 34 | 35 | ## Edge Cases 36 | - Narrow symbol equals standard or missing → fallback to symbol or code. 37 | - Negative zero display under ExceptZero. 38 | - Large magnitudes and small fractions with TrimTrailingZeros. 39 | - Locales with symbol-after-number patterns and spacing afterCurrency rules. 40 | 41 | ## Acceptance Criteria 42 | - Outputs match ICU/CLDR for the golden test matrix. 43 | - Deterministic across Windows/Linux/macOS. 44 | - Meets allocation/throughput targets comparable to Money.ToString() within reasonable overhead (<2–3x when CLDR features engaged). 45 | -------------------------------------------------------------------------------- /features/cldr/feature-provider-model.md: -------------------------------------------------------------------------------- 1 | # Feature Proposal: Provider Model for CLDR-based Formatting & Parsing 2 | 3 | Status: proposal/spec (non-breaking) 4 | Source: docs/feature-cldr-formatting-and-parsing.md 5 | 6 | ## Problem 7 | NodaMoney needs deterministic, locale-accurate currency formatting and parsing aligned with CLDR. Relying only on CultureInfo yields gaps (no currencySpacing, accounting patterns, narrow symbols) and platform variance. 8 | 9 | ## Goals 10 | - Deterministic behavior across platforms by pinning a CLDR version. 11 | - Keep core library lean and AOT-friendly. 12 | - Allow consumers to choose between a lightweight CLDR subset, an ICU-based adapter, or a minimal Culture fallback. 13 | 14 | ## Options (Possible Implementations) 15 | 16 | ### Approach A — ICU adapter (ICU4N) 17 | - Implement Icu4nMoneyLocalePatternProvider using ICU4N for data and formatting. 18 | - Pros: Maximum CLDR fidelity; low maintenance of rules. 19 | - Cons: Larger dependency; potential AOT/trimming issues; version coupling. 20 | - When to use: Enterprises requiring exact ICU parity. 21 | 22 | ### Approach B — Curated CLDR subset (default) 23 | - Implement CldrMoneyLocalePatternProvider with embedded compact tables generated from CLDR JSON. 24 | - Pros: Deterministic, lightweight, AOT-friendly. No heavy runtime deps. 25 | - Cons: Own a small data refresh pipeline. 26 | - When to use: Default for most users. 27 | 28 | ### Approach C — Culture-based fallback 29 | - Implement CultureMoneyLocalePatternProvider using CultureInfo/NumberFormatInfo. 30 | - Pros: Zero additional data/deps; smallest footprint. 31 | - Cons: Feature gaps vs CLDR; platform variance. 32 | - When to use: Constrained environments; diagnostic output. 33 | 34 | ### Approach D — Hybrid model (recommended) 35 | - Ship B as default, C as fallback, and A as optional adapter. 36 | - Select provider via DI/MoneyContext options; allow per-scope overrides. 37 | 38 | ## Selection and Resolution 39 | - DI extension: services.AddMoneyFormatting().UseCldr() | .UseCulture() | .UseIcu4n() 40 | - MoneyContextOptions.FormattingProvider: enum Cldr | Culture | Icu4n (optional) 41 | - Scope override: using MoneyContext.CreateScope(options => options.FormattingProvider = ...) 42 | - Culture parent fallback: if xx-YY missing, try xx, else configured default (e.g., en). 43 | 44 | ## Non-Goals 45 | - Forcing ICU on all users. 46 | - Embedding full CLDR datasets (only a tiny subset needed). 47 | 48 | ## Risks & Mitigations 49 | - Data drift (CLDR updates): Pin version; provide generator tool; document cadence. 50 | - Ambiguous symbols ($): Resolve by locale; expose override hooks in options. 51 | 52 | ## Acceptance Criteria 53 | - Pluggable providers behind a single IMoneyLocalePatternProvider abstraction. 54 | - Default behavior deterministic across platforms when CLDR provider is active. 55 | - DI/Options allow easy selection and scoping. 56 | -------------------------------------------------------------------------------- /src/NodaMoney/Context/StandardRounding.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | namespace NodaMoney.Context; 4 | 5 | /// Represents the standard rounding strategy for monetary calculations. 6 | /// Specifies the strategy that mathematical rounding methods should use to round a number. 7 | /// 8 | /// This class provides a rounding strategy that uses a specified midpoint rounding method, 9 | /// defaulting to (Bankers' rounding). It is commonly 10 | /// used in financial and accounting systems to reduce rounding bias over multiple calculations. 11 | /// 12 | /// 13 | public record StandardRounding(MidpointRounding Mode = MidpointRounding.ToEven) : IRoundingStrategy 14 | { 15 | /// 16 | /// Thrown if is less than 0 17 | /// or greater than 28. 18 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 19 | public decimal Round(decimal amount, CurrencyInfo currencyInfo, int? decimals) 20 | { 21 | if (currencyInfo is null) throw new ArgumentNullException(nameof(currencyInfo)); 22 | 23 | if (currencyInfo.MinorUnit == MinorUnit.NotApplicable) 24 | { 25 | return amount; // No rounding needed 26 | } 27 | 28 | if (decimals is < 0 or > 28) // this pattern also checks for null 29 | { 30 | throw new ArgumentOutOfRangeException(nameof(decimals), "Number of decimal must be between 0 and 28."); 31 | } 32 | 33 | if (!currencyInfo.MinorUnitIsDecimalBased) 34 | { 35 | // If the minor unit system is not decimal-based, we scale up the amount before rounding. For robustness 36 | // and accuracy always use the order "scale up, round, scale down" approach! The steps are: 37 | 38 | // 1. Scale up to normalize it to major units. 39 | decimal scaledUp = amount * currencyInfo.ScaleFactor; 40 | 41 | // 2. Round to the nearest integer (or overriden decimals). 42 | decimal rounded = decimals.HasValue 43 | ? decimal.Round(scaledUp, decimals.Value, Mode) : // round to the specified number of decimals 44 | decimal.Round(scaledUp, Mode); // round to the nearest integer 45 | 46 | // 3. Scale down to return the rounded value in its proper scale. 47 | return rounded / currencyInfo.ScaleFactor; 48 | } 49 | 50 | // If the minor unit of the currency is decimal-based, the rounding is straightforward. The code rounds 51 | // amount to the currency decimal places (or the overriden decimals) using the provided rounding mode. 52 | return decimal.Round(amount, decimals ?? currencyInfo.DecimalDigits, Mode); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/NodaMoney/Context/MoneyContextIndex.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Context; 2 | 3 | /// Represents a unique index limited to 7 bits (0–127), used for the MoneyContext. 4 | /// This is a structure, but limited to 7-bit (0-127), instead of 8-bit. 5 | /// It can be implicitly cast to a and explicit back. 6 | public readonly struct MoneyContextIndex 7 | { 8 | /// Maximum value for the MoneyContextIndex (7 bits) 9 | /// Limited to 7 bits (0-127) due to storage space in the Money type. 10 | private const byte MaxValue = 127; // 7 bits 11 | 12 | /// Tracks next to be assigned index 13 | private static byte s_nextIndex; 14 | 15 | private readonly byte _value; 16 | 17 | /// Private constructor to enforce creation through New(). 18 | /// The actual index value. 19 | private MoneyContextIndex(byte value) 20 | { 21 | _value = value; 22 | } 23 | 24 | /// Allocates and returns the next available MoneyContextIndex. 25 | /// The maximum number of MoneyContexts is reached (=128) 26 | public static MoneyContextIndex New() 27 | { 28 | if (s_nextIndex > MaxValue) 29 | { 30 | throw new InvalidOperationException($"Maximum number of MoneyContexts ({MaxValue + 1}) reached."); 31 | } 32 | 33 | return new MoneyContextIndex(s_nextIndex++); 34 | } 35 | 36 | public static implicit operator byte(MoneyContextIndex index) => index._value; 37 | 38 | public static explicit operator MoneyContextIndex(byte value) => 39 | new(value <= MaxValue 40 | ? value 41 | : throw new ArgumentOutOfRangeException(nameof(value), value, $"MoneyContextIndex must be between 0 and {MaxValue} (7 bits).")); 42 | 43 | public override bool Equals(object? obj) => 44 | obj is MoneyContextIndex other && _value == other._value; 45 | 46 | public override int GetHashCode() => _value; 47 | 48 | public static bool operator ==(MoneyContextIndex left, MoneyContextIndex right) => left._value == right._value; 49 | 50 | public static bool operator !=(MoneyContextIndex left, MoneyContextIndex right) => left._value != right._value; 51 | 52 | public static bool operator <(MoneyContextIndex left, MoneyContextIndex right) => left._value < right._value; 53 | 54 | public static bool operator >(MoneyContextIndex left, MoneyContextIndex right) => left._value > right._value; 55 | 56 | public static bool operator <=(MoneyContextIndex left, MoneyContextIndex right) => left._value <= right._value; 57 | 58 | public static bool operator >=(MoneyContextIndex left, MoneyContextIndex right) => left._value >= right._value; 59 | 60 | public override string ToString() => _value.ToString(); 61 | } 62 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/MoneyNumericInterfaces/NumericOperations.cs: -------------------------------------------------------------------------------- 1 | namespace NodaMoney.Tests.MoneyNumericInterfaces; 2 | 3 | public class NumericOperations 4 | { 5 | [Fact] 6 | public void WhenMultiplicativeIdentity_ReturnOne() 7 | { 8 | // Act 9 | decimal result = Money.MultiplicativeIdentity; 10 | 11 | // Assert 12 | result.Should().Be(1m); 13 | } 14 | 15 | 16 | [Fact] 17 | public void WhenMultipleWithMultiplicativeIdentity_ReturnStartValue() 18 | { 19 | // Arrange 20 | Money startValue = new(123, "EUR"); 21 | 22 | // Act 23 | Money result = startValue * Money.MultiplicativeIdentity; 24 | 25 | // Assert 26 | result.Should().Be(startValue); 27 | } 28 | 29 | [Fact] 30 | public void WhenAdditiveIdentity_ReturnZeroNoCurrencyMoney() 31 | { 32 | // Act 33 | Money result = Money.AdditiveIdentity; 34 | 35 | // Assert 36 | result.Should().Be(new Money(0m, Currency.NoCurrency)); 37 | } 38 | 39 | [Fact] 40 | public void WhenAddingAdditiveIdentity_ReturnStartValue() 41 | { 42 | // Arrange 43 | Money startValue = new(123, "EUR"); 44 | 45 | // Act 46 | Money result = startValue + Money.AdditiveIdentity; 47 | 48 | // Assert 49 | result.Should().Be(startValue); 50 | } 51 | 52 | [Fact] 53 | public void MinValue_ShouldInitializeCorrectly_WhenAccessed() 54 | { 55 | // Act 56 | var result = Money.MinValue; 57 | 58 | // Assert 59 | result.Amount.Should().Be(decimal.MinValue); 60 | result.Currency.Should().Be(Currency.NoCurrency); 61 | } 62 | 63 | [Fact] 64 | public void MinValueWithEur_ShouldInitializeCorrectly_WhenAccessed() 65 | { 66 | // Arrange 67 | Currency eur = CurrencyInfo.FromCode("EUR"); 68 | 69 | // Act 70 | var result = Money.MinValue with { Currency = eur }; 71 | 72 | // Assert 73 | result.Amount.Should().Be(decimal.MinValue); 74 | result.Currency.Should().Be(eur); 75 | } 76 | 77 | [Fact] 78 | public void MaxValue_ShouldInitializeCorrectly_WhenAccessed() 79 | { 80 | // Act 81 | var result = Money.MaxValue; 82 | 83 | // Assert 84 | result.Amount.Should().Be(decimal.MaxValue); 85 | result.Currency.Should().Be(Currency.NoCurrency); 86 | } 87 | 88 | [Fact] 89 | public void MaxValueWithEur_ShouldInitializeCorrectly_WhenAccessed() 90 | { 91 | // Arrange 92 | Currency eur = CurrencyInfo.FromCode("EUR"); 93 | 94 | // Act 95 | var result = Money.MaxValue with { Currency = eur }; 96 | 97 | // Assert 98 | result.Amount.Should().Be(decimal.MaxValue); 99 | result.Currency.Should().Be(eur); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /features/cldr/feature-api-surface.md: -------------------------------------------------------------------------------- 1 | # Feature Proposal: Public API Surface for CLDR Formatting/Parsing 2 | 3 | Status: proposal/spec (non-breaking) 4 | Source: docs/feature-cldr-formatting-and-parsing.md 5 | 6 | ## Objective 7 | Introduce a small, clear, and AOT-friendly API surface to opt-in to CLDR-accurate formatting and parsing without changing existing Money.ToString() behavior. 8 | 9 | ## Proposed APIs 10 | 11 | ```csharp 12 | public enum CurrencyDisplay { Symbol, NarrowSymbol, Code, Name } 13 | public enum SignDisplay { Auto, Always, Never, ExceptZero, Accounting } 14 | 15 | public sealed class MoneyFormatOptions 16 | { 17 | public CultureInfo Culture { get; init; } = CultureInfo.InvariantCulture; 18 | public CurrencyDisplay CurrencyDisplay { get; init; } = CurrencyDisplay.Symbol; 19 | public SignDisplay SignDisplay { get; init; } = SignDisplay.Auto; 20 | public bool UseCashDigits { get; init; } 21 | public int? MinimumFractionDigits { get; init; } 22 | public int? MaximumFractionDigits { get; init; } 23 | public string? NumberingSystem { get; init; } 24 | public bool Grouping { get; init; } = true; 25 | public bool TrimTrailingZeros { get; init; } 26 | } 27 | 28 | public interface IMoneyLocalePatternProvider 29 | { 30 | CurrencyPattern GetPattern(CultureInfo culture); 31 | CurrencyDisplayInfo GetCurrencyDisplayInfo(CultureInfo culture, Currency currency); 32 | CurrencyDigits GetDigits(Currency currency, bool cash); 33 | } 34 | 35 | public interface IMoneyFormatter 36 | { 37 | string Format(Money money, MoneyFormatOptions options); 38 | } 39 | 40 | public interface IMoneyParser 41 | { 42 | bool TryParse(ReadOnlySpan input, CultureInfo culture, out Money result, MoneyFormatOptions? options = null); 43 | } 44 | ``` 45 | 46 | ## Alternatives Considered 47 | - Add overloads on Money.ToString(MoneyFormatOptions options): 48 | - Pros: discoverable; fewer registrations. 49 | - Cons: Pulls formatting concerns into the core type; risks public API bloat and confusion with IFormattable. 50 | - Use AmountFormatQuery-like builder: 51 | - Pros: Fluent; extensible attribute bag. 52 | - Cons: More allocation; less explicit than a strongly-typed options class. 53 | 54 | ## Possible Implementations 55 | - Core (src/NodaMoney): define enums, options, and interfaces only (no behavior). 56 | - Providers/packages implement IMoneyLocalePatternProvider and the concrete IMoneyFormatter/IMoneyParser. 57 | - Add extension methods for ergonomics in a separate static class if desired (e.g., MoneyExtensions.ToString(options)). 58 | 59 | ## Backward Compatibility 60 | - No changes to existing ToString or parsing behavior. 61 | - All new APIs are additive and optional; legacy code remains unaffected. 62 | 63 | ## Acceptance Criteria 64 | - APIs compile across TFMs listed in repo guidelines. 65 | - Nullable reference annotations included; analyzers pass. 66 | - Surface is minimal yet expressive enough for CLDR alignment. 67 | -------------------------------------------------------------------------------- /tests/Benchmark/MoneyOperationsBenchmarks.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Attributes; 2 | using BenchmarkDotNet.Configs; 3 | using NodaMoney; 4 | 5 | namespace Benchmark; 6 | 7 | [MemoryDiagnoser] 8 | public class MoneyOperationsBenchmarks 9 | { 10 | readonly Money _euro10 = Money.Euro(10); 11 | readonly Money _euro20 = Money.Euro(20); 12 | readonly Money _dollar10 = Money.USDollar(10); 13 | Money _euro = new Money(765.43m, "EUR"); 14 | FastMoney _euro10fast = new(10, "EUR"); 15 | readonly FastMoney _euro20fast = new(20, "EUR"); 16 | readonly FastMoney _dollar10fast = new(10, "USD"); 17 | 18 | [Benchmark] 19 | public Money Add() 20 | { 21 | return _euro10 + _euro20; 22 | } 23 | 24 | [Benchmark] 25 | public Money Subtract() 26 | { 27 | return _euro20 - _euro10; 28 | } 29 | 30 | [Benchmark] 31 | public Money Multiple() 32 | { 33 | return _euro10 * 2.2m; 34 | } 35 | 36 | [Benchmark] 37 | public Money Divide() 38 | { 39 | return _euro10 / 2.2m; 40 | } 41 | 42 | [Benchmark] 43 | public Money Increment() 44 | { 45 | return ++_euro; 46 | } 47 | 48 | [Benchmark] 49 | public Money Decrement() 50 | { 51 | return --_euro; 52 | } 53 | 54 | [Benchmark] 55 | public Money Remainder() 56 | { 57 | return _euro20 % _euro10; 58 | } 59 | 60 | [Benchmark(Baseline = true)] 61 | public FastMoney fAdd() 62 | { 63 | return FastMoney.Add(_euro10fast, _euro20fast); 64 | } 65 | 66 | [Benchmark] 67 | public FastMoney fSubtract() 68 | { 69 | return FastMoney.Subtract(_euro20fast, _euro10fast); 70 | } 71 | 72 | [Benchmark] 73 | public FastMoney fMultipleDec() 74 | { 75 | return _euro10fast * 2.2m; 76 | } 77 | 78 | [Benchmark] 79 | public FastMoney fMultipleDecWholeNumber() 80 | { 81 | return _euro10fast * 2m; 82 | } 83 | 84 | [Benchmark] 85 | public FastMoney fMultipleLong() 86 | { 87 | return _euro10fast * 2L; 88 | } 89 | 90 | [Benchmark] 91 | public FastMoney fDivideDec() 92 | { 93 | return _euro10fast / 2.2m; 94 | } 95 | 96 | [Benchmark] 97 | public FastMoney fDivideDecWholeNumber() 98 | { 99 | return _euro10fast / 2m; 100 | } 101 | 102 | [Benchmark] 103 | public FastMoney fDivideLong() 104 | { 105 | return _euro10fast / 2L; 106 | } 107 | 108 | [Benchmark] 109 | public FastMoney fIncrement() 110 | { 111 | return ++_euro10fast; 112 | } 113 | 114 | [Benchmark] 115 | public FastMoney fDecrement() 116 | { 117 | return --_euro10fast; 118 | } 119 | 120 | [Benchmark] 121 | public FastMoney fRemainder() 122 | { 123 | return _euro20fast % _euro10fast; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /tests/NodaMoney.Tests/Serialization/SystemTextJsonSerializationSpec/DeserializeMoney.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | 3 | namespace NodaMoney.Tests.Serialization.SystemTextJsonSerializationSpec; 4 | 5 | public class DeserializeMoney 6 | { 7 | [Theory] 8 | [ClassData(typeof(ValidJsonV1TestData))] 9 | public void WhenDeserializingV1_ThenThisShouldSucceed(string json, Money expected) 10 | { 11 | var clone = JsonSerializer.Deserialize(json); 12 | 13 | clone.Should().Be(expected); 14 | } 15 | 16 | [Theory] 17 | [ClassData(typeof(ValidJsonV2TestData))] 18 | public void WhenDeserializingV2_ThenThisShouldSucceed(string json, Money expected) 19 | { 20 | var clone = JsonSerializer.Deserialize(json); 21 | 22 | clone.Should().Be(expected); 23 | } 24 | 25 | [Theory] 26 | [ClassData(typeof(InvalidJsonV1TestData))] 27 | public void WhenDeserializingWithInvalidJSONV1_ThenThisShouldFail(string json) 28 | { 29 | Action action = () => JsonSerializer.Deserialize(json); 30 | 31 | action.Should().Throw().WithMessage("*property*"); 32 | } 33 | 34 | [Theory] 35 | [ClassData(typeof(InvalidJsonV2TestData))] 36 | public void WhenDeserializingWithInvalidJSONV2_ThenThisShouldFail(string json) 37 | { 38 | Action action = () => JsonSerializer.Deserialize(json); 39 | 40 | action.Should().Throw().WithMessage("*invalid*"); 41 | } 42 | 43 | [Theory] 44 | [ClassData(typeof(NestedJsonV1TestData))] 45 | public void WhenDeserializingWithNestedV1_ThenThisShouldSucceed(string json, Order expected) 46 | { 47 | JsonSerializerOptions options = new() { PropertyNameCaseInsensitive = true }; 48 | var clone = JsonSerializer.Deserialize(json, options); 49 | 50 | clone.Should().BeEquivalentTo(expected); 51 | } 52 | 53 | [Theory] 54 | [ClassData(typeof(NestedJsonV2TestData))] 55 | public void WhenDeserializingWithNestedV2_ThenThisShouldSucceed(string json, Order expected) 56 | { 57 | JsonSerializerOptions options = new() { PropertyNameCaseInsensitive = true }; 58 | var clone = JsonSerializer.Deserialize(json, options); 59 | 60 | clone.Should().BeEquivalentTo(expected); 61 | } 62 | 63 | [Fact] 64 | public void WhenNullableOrderWithTotalIsNull_ThenThisShouldSucceed() 65 | { 66 | // Arrange 67 | //var order = new NullableOrder { Id = 123, Name = "Foo", Total = null }; 68 | var order = new NullableOrder { Id = 123, Name = "Foo" }; 69 | string json = $$"""{"Id":123,"Total":null,"Name":"Foo"}"""; 70 | 71 | // Act 72 | JsonSerializerOptions options = new() { PropertyNameCaseInsensitive = true }; 73 | var deserialized = JsonSerializer.Deserialize(json, options); 74 | 75 | // Assert 76 | deserialized.Should().BeEquivalentTo(order); 77 | deserialized.Total.Should().BeNull(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tests/Benchmark/HighLoadBenchmarks.cs: -------------------------------------------------------------------------------- 1 | using System.Data.SqlTypes; 2 | using BenchmarkDotNet.Attributes; 3 | using NodaMoney; 4 | using NodaMoney.Context; 5 | 6 | namespace Benchmark; 7 | 8 | [MemoryDiagnoser] 9 | //[Config(typeof(InProcessConfig))] 10 | public class HighLoadBenchmarks 11 | { 12 | //[Params(1, 100, 1_000, 100_000, 1_000_000)] 13 | public int Count { get; set; } = 1_000_000; 14 | 15 | [Benchmark] 16 | public Currency[] Create1MCurrency() 17 | { 18 | Currency[] currencies = new Currency[Count]; 19 | 20 | for (int i = 0; i < Count; i++) 21 | { 22 | if (i % 3 == 0) 23 | currencies[i] = CurrencyInfo.FromCode("EUR"); 24 | else if (i % 2 == 0) 25 | currencies[i] = CurrencyInfo.FromCode("USD"); 26 | else 27 | currencies[i] = CurrencyInfo.FromCode("JPY"); 28 | } 29 | 30 | return currencies; 31 | } 32 | 33 | [Benchmark(Baseline = true)] 34 | public Money[] Create1MMoney() 35 | { 36 | Money[] money = new Money[Count]; 37 | 38 | for (int i = 0; i < Count; i++) 39 | { 40 | if (i % 3 == 0) 41 | money[i] = new Money(10M, "EUR"); 42 | else if (i % 2 == 0) 43 | money[i] = new Money(20M, "USD"); 44 | else 45 | money[i] = new Money(30M, "JPY"); 46 | } 47 | 48 | return money; 49 | } 50 | 51 | [Benchmark] 52 | public decimal Create1MFastMoney() 53 | { 54 | FastMoney[] money = new FastMoney[Count]; 55 | 56 | for (int i = 0; i < Count; i++) 57 | { 58 | if (i % 3 == 0) 59 | money[i] = new FastMoney(10M, "EUR"); 60 | else if (i % 2 == 0) 61 | money[i] = new FastMoney(20M, "USD"); 62 | else 63 | money[i] = new FastMoney(30M, "JPY"); 64 | } 65 | 66 | return money[0].Amount; 67 | } 68 | 69 | [Benchmark] 70 | public SqlMoney Create1MSqlMoney() 71 | { 72 | SqlMoney[] money = new SqlMoney[Count]; 73 | 74 | for (int i = 0; i < Count; i++) 75 | { 76 | if (i % 3 == 0) 77 | money[i] = new SqlMoney(10M); 78 | else if (i % 2 == 0) 79 | money[i] = new SqlMoney(20M); 80 | else 81 | money[i] = new SqlMoney(30M); 82 | } 83 | 84 | return money[0]; 85 | } 86 | 87 | [Benchmark] 88 | public decimal Create1MDecimal() 89 | { 90 | decimal[] decimals = new decimal[Count]; 91 | 92 | for (int i = 0; i < Count; i++) 93 | { 94 | if (i % 3 == 0) 95 | decimals[i] = 10M; 96 | else if (i % 2 == 0) 97 | decimals[i] = 20M; 98 | else 99 | decimals[i] = 30M; 100 | } 101 | 102 | return decimals[0]; 103 | } 104 | } 105 | --------------------------------------------------------------------------------