# Why Decimal Arithmetic Can Still Surprise You in .NET

Switching from `double` to `decimal` solves an important problem when working with money. It lets you represent values such as `0.1m` exactly in base ten. The thing is though, It doesnt tell your application when to round, how to share a spare penny between line items, or which total an invoice is supposed to reconcile to. Those decisions tend to surface late. A calculation looks correct in a unit test, then a customer finds that three discounted lines add up to a different amount from the invoice discount. Every number is a `decimal`. Every calculation is internally consistent. The disagreement comes from applying two different rounding policies.

## The penny that appears twice

Suppose an invoice has three lines of €0.05, each eligible for a 10% discount. The unrounded discount on each line is €0.005. If you round each line independently to cents using `MidpointRounding.AwayFromZero`, each discount becomes €0.01. The invoice shows a €0.03 discount. If you calculate 10% of the €0.15 subtotal and then round, the discount is €0.02. Both calculations used decimal arithmetic. They simply rounded at different points.

```csharp
var lines = new[] { 0.05m, 0.05m, 0.05m };
const decimal rate = 0.10m;

var sumOfRoundedLines = lines.Sum(line =>
    Math.Round(line * rate, 2, MidpointRounding.AwayFromZero));

var roundedInvoiceDiscount = Math.Round(
    lines.Sum() * rate,
    2,
    MidpointRounding.AwayFromZero);

Console.WriteLine(sumOfRoundedLines);      // 0.03
Console.WriteLine(roundedInvoiceDiscount); // 0.02
```

The example is tiny, but the difference appears in discounts, fees, commissions, tax and currency conversion. The right answer depends on the applicable contract, accounting or tax rules. The code cannot infer whether the invoice total should be calculated from individually rounded lines or whether a total should be calculated first and allocated back to the lines. Notice that this is not a floating point precision failure. The intermediate value `0.005m` is exactly representable as a `decimal`. You still have to decide whether it becomes zero or one cent, and when that decision is made.

## The default rounding mode is a policy

In .NET, `Math.Round(decimalValue, 2)` uses `MidpointRounding.ToEven` by default. At an exact midpoint, this rounds towards the result whose last retained digit is even. For example, `1.005m` rounded to two places becomes `1.00m`; `1.015m` becomes `1.02m`. If you choose `MidpointRounding.AwayFromZero`, they become `1.01m` and `1.02m` respectively. Neither mode is universally correct for money. A published rate, contract or jurisdiction may require a particular method, and sometimes it specifies the calculation level as well as the rounding direction. Put that choice in the calculation's name or configuration. Do not leave it buried in whichever `Math.Round` overload a developer happened to use.

```csharp
static decimal RoundInvoiceDiscount(decimal amount) =>
    Math.Round(amount, 2, MidpointRounding.AwayFromZero);
```

The name in this example is deliberately specific. It does not pretend that all monetary amounts in the application use the same rule. You might need separate policies for line charges, invoice tax, refunds and reported totals. Apply the rule at a named boundary and preserve higher precision until that boundary where the specification permits it. Formatting is a different boundary. `amount.ToString("C2")` changes what is displayed; it does not establish the stored value or reconcile the calculation. A value that prints as €1.23 may still contain fractions of a cent in memory.

## Rounding a total means allocating it

Sometimes the business rule says to calculate the invoice level discount first, then display an amount against each line. Once the rounded total has been fixed, the line amounts must be an allocation of that total. Independently rounding each line will not reliably reconcile. Take a €0.05 discount split equally among three lines. Each exact share is one and two-thirds cents. The displayed shares must contain whole cents. One deterministic allocation is €0.02, €0.02 and €0.01. The third line is not inherently less deserving; it received the remainder according to a documented tie-break rule.

A common allocation method is to work in minor units. Calculate each proportional share, assign its whole unit part, then distribute the remaining units to the largest fractional remainders. When remainders are equal, use a stable identifier to decide where the extra unit goes. Sorting by the current position in a collection can make the result change when someone reorders the lines. Here is a compact implementation for a non-negative amount in minor units and positive weights. It returns line IDs and allocated units in the original input order. For example, `Allocate(5, ...)` with three equal weights allocates `2, 2, 1`.

```csharp
public sealed record AllocationInput(string LineId, decimal Weight);
public sealed record AllocationResult(string LineId, long MinorUnits);

public static IReadOnlyList<AllocationResult> Allocate(
    long totalMinorUnits,
    IReadOnlyList<AllocationInput> lines)
{
    if (totalMinorUnits < 0 || lines.Count == 0 ||
        lines.Any(line => string.IsNullOrWhiteSpace(line.LineId) ||
                          line.Weight <= 0m) ||
        lines.Select(line => line.LineId)
             .Distinct(StringComparer.Ordinal).Count() != lines.Count)
    {
        throw new ArgumentException("Invalid allocation inputs.");
    }

    var totalWeight = lines.Sum(line => line.Weight);
    var shares = lines.Select((line, index) =>
    {
        var exact = totalMinorUnits * line.Weight / totalWeight;
        var whole = (long)decimal.Truncate(exact);

        return new Share(line.LineId, index, whole, exact - whole);
    }).ToArray();

    var remaining = checked((int)(totalMinorUnits -
        shares.Sum(share => share.MinorUnits)));

    foreach (var share in shares
        .OrderByDescending(share => share.Remainder)
        .ThenBy(share => share.LineId, StringComparer.Ordinal)
        .Take(remaining))
    {
        share.MinorUnits++;
    }

    return shares
        .OrderBy(share => share.Index)
        .Select(share => new AllocationResult(
            share.LineId,
            share.MinorUnits))
        .ToArray();
}

internal sealed class Share(
    string lineId,
    int index,
    long minorUnits,
    decimal remainder)
{
    public string LineId { get; } = lineId;
    public int Index { get; } = index;
    public long MinorUnits { get; set; } = minorUnits;
    public decimal Remainder { get; } = remainder;
}
```

This is an example of a specific allocation rule, not a universal money library. It assumes positive weights, distinct stable line IDs and an amount that fits the chosen numeric types. Production code should define what happens with zero value lines, negative amounts, returns, minimum charges and unusually large inputs. It should also define the currency's minor unit: two decimal places are common, but they are not universal. The invariant is more important than the particular helper: allocated line amounts must sum to the amount the invoice says was allocated. Save the chosen allocation, or enough information to reproduce it, if the invoice may need to be explained later.

## Conversions and persistence introduce more rounding points

A money value can cross several representations before it reaches an invoice. The API accepts a decimal value, a database column stores a fixed scale, a query multiplies it by a rate, the application rounds a result, and the UI formats it for display. If the database column accepts two fractional digits but the calculation expects four, precision has been lost before your C# rounding method runs. Define the precision required for rates and intermediate results separately from the precision of payable amounts. Check database column types and casts, especially around multiplication and division. Make sure the application and database agree on where rounding occurs. A calculation performed partly in SQL and partly in C# deserves an explicit reconciliation check.

Parsing deserves similar care. A decimal literal such as `0.1m` is suitable in C# code. A user entered `1,234` needs a specified culture and an unambiguous meaning before it becomes an amount. The currency code should travel with the number at the system boundary; `100.00m` alone cannot tell you what unit it represents. Currency conversion adds another policy question. Converting each line and rounding may produce a different total from converting the original total once. The rate's precision, effective time, rounding point and allocation method all affect the outcome. Persisting only the final amount can make a later reconciliation impossible.

## Make discrepancies testable

I would test the calculation around midpoint values, very small lines, uneven proportions and multiple equal remainders. I would also assert the relationships between results - line allocations sum to their declared total, a refund follows the defined reversal policy, and reordering input lines does not change which stable ID receives a remainder. A test for `Math.Round(1.005m, 2, ...)` proves that .NET applied a mode. A test for an entire invoice proves that your chosen rounding and allocation rules work together. That is where the expensive discrepancies tend to hide.

`decimal` gives you a sound representation for base ten monetary calculations within its precision and range. The application still has to say which amount is authoritative, where precision is reduced and who receives the leftover penny. Write those rules down, give them names and make reconciliation a property of the result rather than a manual exercise after a customer notices the difference.

[C# numeric type reference](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/floating-point-numeric-types)

[Math.Round documentation](https://learn.microsoft.com/en-us/dotnet/api/system.math.round?view=net-10.0)

[MidpointRounding reference](https://learn.microsoft.com/en-us/dotnet/fundamentals/runtime-libraries/system-midpointrounding)
