NodaMoney .NET Library

repository·master·Indexed 42 days ago

https://github.com/remyduijkeren/nodamoney

A .NET library for type-safe money and currency handling. It provides currency-aware arithmetic, correct rounding, and a locale-aware formatting and parsing system driven by CLDR (Common Locale Data Repository) via IMoneyFormatter and IMoneyParser. Key features include the MoneyContext for managing precision and scale, and extensible monetary logic through IMoneyOperator and IMoneyQuery.

Tokens
34.9K
Snippets
90
Records
136
Agent score
53%

What's inside NodaMoney

  1. Overview of NodaMoney Feature Proposals

    master

    The features/proposals directory contains detailed design documents for proposed enhancements to NodaMoney. These proposals are derived from comparative analyses with JavaMoney and JodaMoney. Each proposal outlines the rationale for a feature, a suggested architectural direction, and concrete API sketches or implementation options.

    Developers looking to contribute to the roadmap or understand the intended evolution of the library can consult these individual proposal files.

  2. Align MoneyContext for Binary Operations

    master

    Binary operations (like + or -) between two Money instances require that both instances use the exact same MoneyContext. If they use different contexts, an InvalidOperationException will be thrown to prevent calculation errors.

    To resolve this, use the with expression to align the context of one instance with the other, or create a new context and apply it to both.

    // This will throw InvalidOperationException if contexts differ:
    // var result = money1 + money2;
    
    // Option 1: Align contexts using 'with'
    var result = money1 + (money2 with { Context = money1.Context });
    
    // Option 2: Use a shared third context
    var retailContext = MoneyContext.Create(MidpointRounding.AwayFromZero);
    var money1WithRetail = money1 with { Context = retailContext };
    var money2WithRetail = money2 with { Context = retailContext };
    var result = money1WithRetail + money2WithRetail;
  3. How provider selection and defaults work

    master

    NodaMoney uses a probing mechanism to determine the default formatting provider if one is not explicitly specified:

    1. CLDR Priority: If the CLDR package is referenced in your project, the system defaults to CldrMoneyLocalePatternProvider.
    2. Culture Fallback: If the CLDR package is not found, the system defaults to CultureMoneyLocalePatternProvider.
    3. Explicit Overrides: You can always override these defaults using the DI fluent API (.UseCldr(), .UseCulture(), etc.) or via MoneyContextOptions.

    Error Handling: If you explicitly select a provider (e.g., via .UseIcu4n()) but the required package is not available in your project, an error will be thrown during service wiring with a clear message.

  4. Configure advanced money formatting with MoneyFormatOptions

    master

    While NodaMoney supports standard format specifiers (e.g., G, C, R, N, L, l), advanced formatting requirements like accounting styles, specific spacing policies, and decimal precision can be managed via the MoneyFormatOptions record. This allows for fine-grained control over how money is displayed without relying solely on opaque format strings.

    Key configuration properties include:

    • SymbolPlacement: Controls whether the currency symbol appears Before or After the amount.
    • SpaceBetween: Defines the space type between the number and symbol (None, Normal, NonBreaking, or NarrowNonBreaking).
    • NegativeStyle: Sets how negative values are shown (Minus, Parentheses, or TrailingMinus).
    • MinDecimals / MaxDecimals: Overrides the default currency minor units for display purposes.
    • PreserveTrailingZeros: Ensures a specific number of decimal places are shown even if they are zero.
    • CashDisplay: When true, formatting can follow cash denomination rounding (e.g., rounding CHF to the nearest 0.05) for display purposes without altering the underlying stored amount.
    public sealed record MoneyFormatOptions
    {
        public IFormatProvider? Culture { get; init; }
        public bool UseIsoCode { get; init; }
        public bool UseSymbol { get; init; }
        public SymbolPlacement SymbolPlacement { get; init; } // Before|After
        public SpacePolicy SpaceBetween { get; init; } // None|Normal|NonBreaking|NarrowNonBreaking
        public NegativeStyle NegativeStyle { get; init; } // Minus|Parentheses|TrailingMinus
        public int? MinDecimals { get; init; }
        public int? MaxDecimals { get; init; }
        public bool PreserveTrailingZeros { get; init; }
        public bool QuantizeToCurrencyMinorUnits { get; init; } = true;
        public bool CashDisplay { get; init; }
    }
  5. Implementation considerations for CLDR formatting and parsing

    master

    When working with CLDR-based currency formatting and parsing in NodaMoney, be aware of the following behaviors and constraints:

    • Spacing Rules: By default, a Non-Breaking Space (NBSP) is inserted between the currency symbol and digits when they are adjacent. While CLDR provides advanced regex for spacing, NodaMoney uses simplified versions to maintain correctness across most locales.
    • Symbol Ambiguity: Symbols like $ are disambiguated based on the provided locale (e.g., en-US maps to USD, while en-CA maps to CAD). You can use option hooks to provide manual overrides if necessary.
    • Symbol Fallbacks: If a narrow currency symbol is missing or identical to the standard symbol, the system implements a graceful fallback.
    • Indian Grouping: Locales using the 3;2;2... grouping pattern (common in India) require explicit handling.
    • Currency Filtering: By default, the system filters for active ISO currencies. Historical currencies present in CLDR data are available but require an explicit opt-in.
  6. Understand CLDR-aligned currency parsing behavior

    master

    NodaMoney's parsing logic is designed to be CLDR-aligned, meaning it aims to parse locale-formatted currency strings back into Money objects by recognizing locale-specific symbols, codes, spacing, and grouping.

    Key parsing capabilities include:

    • Locale-aware tokenization: Recognizes decimal and grouping symbols, including various whitespace types like NBSP (Non-Breaking Space) and narrow NBSP.
    • Currency resolution: Uses a longest-match policy to identify currency via symbols, narrow symbols, or ISO codes. If a symbol is ambiguous (e.g., $), it defaults to the currency associated with the current locale (e.g., en-CA resolves to CAD).
    • Sign handling: Supports both explicit minus signs and accounting-style parentheses (e.g., ($10.00)).
    • Fraction digits: Accepts inputs with varying fraction digits, with rounding managed by the MoneyContext after parsing.
    • Grouping validation: Currently uses lenient grouping validation by default.
  7. How Named Contexts work for multi-currency applications

    master

    When an application needs to handle multiple sets of currency or rounding rules simultaneously (e.g., a multi-regional e-commerce platform), you can register multiple named contexts.

    1. Registration: Use the name parameter in AddMoneyContext to distinguish configurations.
    2. Resolution: Inject IMoneyContextResolver into your services to retrieve a specific context by its name.
    3. Usage: Use MoneyContext.CreateScope(context) to apply the retrieved context to your calculations within a specific scope.
    // Register multiple named contexts
    services.AddMoneyContext(options => {
        options.DefaultCurrency = Currency.FromCode("USD");
        options.RoundingStrategy = new HalfUpRounding();
    }, name: "US");
    
    services.AddMoneyContext(options => {
        options.DefaultCurrency = Currency.FromCode("EUR");
        options.RoundingStrategy = new HalfEvenRounding();
    }, name: "EU");
    
    // Access named contexts in your services
    public class InternationalPaymentService
    {
        private readonly IMoneyContextResolver _contextResolver;
    
        public InternationalPaymentService(IMoneyContextResolver contextResolver)
        {
            _contextResolver = contextResolver;
        }
    
        public Money CalculatePrice(string region, decimal amount)
        {
            // Get appropriate context based on region
            var contextName = region == "US" ? "US" : "EU";
            var context = _contextResolver.GetContext(contextName);
    
            // Use context for calculation
            using var scope = MoneyContext.CreateScope(context);
            return new Money(amount); // Uses the scoped context
        }
    }
  8. Understand the difference between IRoundingStrategy and IRoundingProvider

    master

    NodaMoney distinguishes between the algorithm that performs rounding and the logic used to select that algorithm:

    • IRoundingStrategy: A concrete implementation of a rounding algorithm (e.g., StandardRounding.ToEven).
    • IRoundingProvider: A selector or factory that determines which IRoundingStrategy should be used based on a RoundingQuery.

    When performing operations, the system resolves the strategy using the following precedence:

    1. An explicit RoundingStrategy defined in MoneyContextOptions.
    2. A strategy returned by an IRoundingProvider via GetRounding(query).
    3. The fallback StandardRounding.ToEven.
    // The provider selects the strategy based on the query
    public interface IRoundingProvider
    {
        IRoundingStrategy GetRounding(in RoundingQuery query);
    }
    
    // The strategy performs the actual math
    // Example fallback logic in MoneyContext:
    // RoundingStrategy => Options.RoundingStrategy ?? Options.RoundingProvider?.GetRounding(BuildQuery()) ?? StandardRounding.ToEven
  9. Use RoundingQuery to drive rounding selection

    master

    A RoundingQuery is a data structure used by providers to decide which rounding strategy to apply. It contains the following properties:

    • CurrencyInfo? Currency: The currency context.
    • bool IsCash: Indicates if the transaction is a cash transaction.
    • int? Scale: The target decimal scale.
    • DateTimeOffset? When: The timestamp for the operation.
    • IReadOnlyDictionary<string, object?>? Attributes: A dictionary for custom flags or metadata used for complex selection logic.
    readonly record struct RoundingQuery(
        CurrencyInfo? Currency, 
        bool IsCash = false, 
        int? Scale = null, 
        DateTimeOffset? When = null, 
        IReadOnlyDictionary<string, object?>? Attributes = null
    );
  10. Handle temporal aspects in rounding and conversion queries

    master

    To account for changes in VAT, exchange rates, or denomination policies over time, use temporal semantics when performing rounding or conversion operations. Instead of using current policies, you can specify a point in time to retrieve historical snapshots.

    When constructing queries, use the following parameters to select the appropriate policy:

    • For rounding: Use RoundingQuery with the When property (type DateTimeOffset?).
    • For conversion: Use ConversionQuery with the At property (type DateTimeOffset?).

    It is recommended to use UTC for all timestamps to avoid time-zone ambiguity.

  11. Use FastMoney for high-throughput calculations

    master

    The FastMoney type is an optimized version of Money designed for maximum performance in high-throughput scenarios. Unlike Money (which is based on decimal), FastMoney is based on long using 64-bit integer arithmetic with a fixed four-decimal scale.

    Key Characteristics:

    • Precision: 17 digits (±1.0 × 10^-17 to ±9.2 × 10^17).
    • Scale: Fixed four decimal places.
    • Performance: Up to 18 times faster for arithmetic operations (add, subtract, multiply, divide, etc.).
    • Size: 12 bytes (vs. 16 bytes for Money).
    • Compatibility: Aligns with SqlMoney and OLE Automation Currency (OACurrency).

    Note: Use FastMoney only when performance is critical and you can manage the loss of precision and fixed rounding. For presentation, formatting, or rounding based on currency minor units, convert FastMoney to Money using .ToMoney() or an explicit cast.

    using NodaMoney;
    
    var eur = new FastMoney(10.1234m, "EUR");
    var fee = new FastMoney(0.1000m, "EUR");
    var total = eur + fee;              // currency-safe operations
    
    // Convert to Money for formatting/rounding/presentation
    Money display = eur.ToMoney();      // or (Money)eur
    var text = display.ToString("C");
    
    // Convert from Money (explicit)
    var money = new Money(12.34m, "EUR");
    FastMoney fast = (FastMoney)money;  // or new FastMoney(money)
    
    // Convert from SqlMoney
    SqlMoney sqlMoney = db.MoneyFromDb();
    FastMoney? fast = FastMoney.FromSqlMoney(sqlMoney, Currency.FromCode("EUR")); // or (FastMoney?)sqlMoney
    
    // Convert to SqlMoney
    SqlMoney sqlMoney1 = fast.ToSqlMoney();
    
    // Convert from OLE Automation Currency
    long oaCurrency = db.CurrencyFromDb();
    FastMoney fast = FastMoney.FromOACurrency(oaCurrency, Currency.FromCode("EUR"));
    
    // Convert to OLE Automation Currency
    long oaCurrency = fast.ToOACurrency();