Mert Özen Further With Every Line
Software Design Solution Patterns — #17

Money and Rounding: decimal, a Money Value Object, VAT and Distributing Cents

Mert Özen Nov 7, 2026 14 min 3 views
Money and Rounding: decimal, a Money Value Object, VAT and Distributing Cents

Modeling money correctly takes a Money value object that carries the amount with its currency, the same explicit rounding rule in C# and SQL, and Allocate instead of division so no cent is lost. The post shows Math.Round and SQL ROUND disagreeing on 0.125, the 1-cent difference between per-line and total VAT, an effective-dated VAT rate table (20%, 10%, 1% as of November 2026), EF Core complex type mapping and string amounts in JSON, with real output.

Version note: The C# code in this post was tested with .NET 10 and EF Core 10, and the SQL examples with SQL Server 2025.

Guess First: How Many Kuruş Is 1 Percent of 12.50 TL?

One of the canteen items has a net amount of 12.50 TL and is subject to 1 percent VAT. The VAT is exactly 0.125 TL, half a kuruş (the Turkish cent). The same value is rounded to two decimals in the backend and in the database:

decimal vat = 12.50m * 0.01m;
Console.WriteLine($"Math.Round default      : {Math.Round(vat, 2)}");
SELECT ROUND(0.125, 2) AS RoundHalf, CAST(0.125 AS decimal(18,2)) AS Half;

Does C# say 0.12 or 0.13? Does SQL Server give the same answer? And a second question: on an invoice with three subscription lines of 33.33 TL per seat, do you calculate 20 percent VAT on each line separately or once on the total? Do you get the same result? Both answers are one kuruş, but that kuruş is a reconciliation difference in accounting, a complaint from the customer and a question in an audit.

Context and Problem

The HR Portal's billing module issues monthly per-seat subscription invoices to tenants; some customers abroad pay in euros. Corporate customers want to pay the yearly amount in installments. In the expense module, employees enter expenses subject to different VAT rates: meals, accommodation, stationery. There's money everywhere, and the same questions come up everywhere: which type, how many decimals, which rounding rule, at what moment to round, which currency and which date's rate.

An amount without a currency is half a piece of information. 1,000 is one thing in TL and another in euros; adding them isn't a bug, it's meaningless. Martin Fowler's Money pattern in the "Patterns of Enterprise Application Architecture" catalog is born exactly here: the amount and the currency are a single value, and arithmetic and rounding are that value's responsibility.

Symptoms

If your money design has problems, you'll see these: there's a 1-kuruş difference between the invoice total and the sum of its lines, and accounting fixes it by hand every month. When a 100 TL payment is split into three installments, the installments add up to 99.99 TL. A query using double prints values like 0.30000000000000004 in reports. A service added a TL amount to a euro amount and nobody noticed. The VAT rate is written in code as 0.18m, and after the 2023 rate change, reprints of past invoices were calculated with the new rate. The total calculated in Angular differs from the total the API returns.

The Naive Solution and Why It Breaks

The first version kept amounts in double. Adding 0.1 ten times:

double d = 0;
for (int i = 0; i < 10; i++) d += 0.1;
decimal m = 0;
for (int i = 0; i < 10; i++) m += 0.1m;
Console.WriteLine($"double: {d:R} == 1.0 ? {d == 1.0}");
Console.WriteLine($"decimal: {m} == 1.0m ? {m == 1.0m}");
double: 0.9999999999999999 == 1.0 ? False
decimal: 1.0 == 1.0m ? True

double is a binary floating-point number and can't represent 0.1 exactly; decimal is base ten and holds cents exactly (the C# series post "Numbers and Arithmetic in C#: int, double, decimal and Overflow" explains the difference in detail). Moving to decimal is necessary but not sufficient. The answer to the opening question:

Math.Round default      : 0.12
RoundHalf Half
--------- ----
.130 .13

Math.Round defaults to MidpointRounding.ToEven, which rounds a half to the nearest even number ("banker's rounding"). SQL Server's ROUND function and conversion to decimal(18,2), on the other hand, round a half away from zero. The same invoice shows 0.12 VAT in C# and 0.13 in a report query. The breaking point is the moment two layers round the same value with different rules.

The third naive reflex is division. Splitting 100 TL into three installments:

naive 100/3   : 33.33 TRY, 33.33 TRY, 33.33 TRY

The total is 99.99 TL. A kuruş disappeared; either the customer will be undercharged or the last installment will be fixed by hand.

Options

A) decimal Everywhere

Amounts are decimal, the currency is a separate property or absent, and rounding happens at each point of use. You start with the least code. Mixed currencies, rounding rules that differ between layers and distributing cents are left to each developer's attention.

B) A Money Value Object

Fowler's Money pattern: Amount and Currency together, as an immutable value object. Adding different currencies throws, rounding happens in one place based on the currency's number of decimals, and Allocate replaces division. In C#, a readonly record struct is a perfect fit: value equality, immutability and allocation-free passing (the C# series post "C# record and record struct: Value Equality and the with Expression").

C) Integer Minor Units

Storing the amount as a long in the smallest unit: 3333 kuruş instead of 33.33 TL. Common in payment provider APIs; rounding problems disappear completely for addition and subtraction. But multiplying by a rate (VAT, exchange rate) still produces fractions in intermediate values, currencies have different numbers of decimals (yen 0, dinar 3), and every screen has to remember to divide by 100. Useful at the boundary (APIs, payment providers); in the domain model B reads better.

D) SQL Server's money Type

SQL Server's money type is a fixed-point type with 4 decimals. It works for storage, but loses precision in intermediate calculations and knows nothing about currencies:

DECLARE @m money = 10, @d decimal(19,4) = 10;
SELECT @m / 3 * 3 AS MoneyResult, @d / 3 * 3 AS DecimalResult, @d / 3 AS DecimalDiv;
MoneyResult DecimalResult DecimalDiv
----------- ------------- ----------
9.9999 9.999999 3.333333

money truncates to 4 decimals in division; decimal computes the result's precision from the operands' precision and scale. Neither is "exact", but decimal's rules are documented and it's the same concept as .NET's decimal. In a new design, prefer decimal(19,4).

For the HR Portal we choose B; in the database, decimal(19,4) and a separate currency column.

Implementation: Money, Rounding and Distributing Cents

public readonly record struct Currency(string Code, int MinorUnits)
{
    public static readonly Currency TRY = new("TRY", 2);
    public static readonly Currency EUR = new("EUR", 2);
    public static readonly Currency JPY = new("JPY", 0);
}

public readonly record struct Money(decimal Amount, Currency Currency)
{
    public static Money Try(decimal amount) => new(amount, Currency.TRY);

    public static Money operator +(Money a, Money b) => new(a.Amount + b.Amount, Same(a, b));
    public static Money operator -(Money a, Money b) => new(a.Amount - b.Amount, Same(a, b));
    public static Money operator *(Money a, decimal factor) => new(a.Amount * factor, a.Currency);

    private static Currency Same(Money a, Money b) => a.Currency == b.Currency
        ? a.Currency
        : throw new InvalidOperationException($"Cannot combine {a.Currency.Code} and {b.Currency.Code}.");

    public Money Round(MidpointRounding mode = MidpointRounding.AwayFromZero) =>
        new(Math.Round(Amount, Currency.MinorUnits, mode), Currency);
}

There are three design decisions. The currency is part of the amount, and different currencies can't be combined:

Cannot combine TRY and EUR.

The number of decimals comes from the currency: 0 for yen, 2 for lira and euro. And the rounding rule is chosen explicitly: AwayFromZero. Why not ToEven? Banker's rounding balances rounding up and down over a large number of operations and is statistically unbiased; but for a single amount on an invoice, the rule the customer, the accounting software and the database expect is usually "half up". As much as which one you choose, what matters is that the choice is the same in every layer; ours matches SQL Server's ROUND. AwayFromZero is symmetric for negative amounts: -0.125 → -0.13. If your business rule is different (for example always round down in the customer's favor), name it with an enum; don't leave it to Math.Round's default.

Fowler's answer to not losing cents is allocate: the amount is converted to the smallest unit, shared out as integers by the ratios, and the remaining units are handed out one by one starting from the beginning.

// allocate from Fowler's Money pattern: leftover cents are handed out one by one from the start
public Money[] Allocate(params int[] ratios)
{
    decimal unit = 1m / (decimal)Math.Pow(10, Currency.MinorUnits);
    long totalUnits = (long)(Round().Amount / unit);
    int ratioSum = ratios.Sum();

    long[] shares = [.. ratios.Select(r => totalUnits * r / ratioSum)];
    long remainder = totalUnits - shares.Sum();
    for (int i = 0; remainder > 0; i = (i + 1) % shares.Length, remainder--)
        shares[i]++;

    Currency currency = Currency;
    return [.. shares.Select(s => new Money(s * unit, currency))];
}
100 TRY / 3   : 33.34 TRY, 33.33 TRY, 33.33 TRY
0.05 TRY 70/30: 0.04 TRY, 0.01 TRY

The second line is the example from Fowler's book: splitting 5 cents 70 and 30 percent. Simple multiplication gives 3.5 and 1.5 cents; round both and you get 4 + 2 = 6 cents, truncate both and you get 3 + 1 = 4 cents. Allocate always preserves the total. Whether the extra cent of an installment plan goes to the first or the last installment is a business decision; you can choose it by changing the order of the ratios.

VAT per Line or on the Total?

The second opening question. A three-line invoice and two calculations:

// Per line: each line's VAT is rounded separately
public static Money[] PerLine(IReadOnlyList<InvoiceLine> lines) =>
    [.. lines.Select(l => (l.Net * l.VatRate).Round())];

// On the total: rounded once per rate group, the difference is allocated to the lines
public static Money[] FromTotal(IReadOnlyList<InvoiceLine> lines)
{
    Money[] result = new Money[lines.Count];
    foreach (IGrouping<decimal, int> group in Enumerable.Range(0, lines.Count).GroupBy(i => lines[i].VatRate))
    {
        int[] idx = [.. group];
        Money groupNet = idx.Select(i => lines[i].Net).Aggregate((a, b) => a + b);
        Money groupVat = (groupNet * group.Key).Round();
        int[] weights = [.. idx.Select(i => (int)(lines[i].Net.Amount * 100))];
        Money[] shares = groupVat.Allocate(weights);
        for (int k = 0; k < idx.Length; k++) result[idx[k]] = shares[k];
    }
    return result;
}
per line : 6.67 TRY + 6.67 TRY + 6.67 TRY = 20.01 TRY
from total: 6.67 TRY + 6.67 TRY + 6.66 TRY = 20.00 TRY

6.666 TL of VAT per line, rounded up three times, gives 20.01 TL; 20 percent of 99.99 TL is 19.998 → 20.00 TL. Both are defensible; which one to use is a decision to make together with accounting and your e-invoice integration. The one rule that doesn't change is that the document must be internally consistent: the sum of the line VAT amounts must equal the document's VAT. If you calculate from the total, distribute the difference to the lines with Allocate; if you calculate per line, derive the document total from the sum of the lines and don't recalculate it separately. Different rates (1, 10, 20 percent) are always totaled in separate groups, because an e-invoice also carries VAT per rate in the document.

Implementation: Rates, the Database and JSON

A VAT rate isn't a constant; it's time-dependent data. According to the Turkish Revenue Administration's (GİB) "Current VAT Rates" text, Presidential Decision No. 7346 raised the general rate from 18 to 20 percent and the rate for list (II) from 8 to 10 percent as of 10.07.2023; the rate for list (I) is 1 percent. In November 2026, when this post was written, the current text published by GİB still lists these rates. Instead of writing 0.20m in code, read the rate from an effective-dated table:

public enum VatCategory { General, Reduced, Basic }

public sealed record VatRate(VatCategory Category, DateOnly ValidFrom, DateOnly? ValidTo, decimal Rate);

public sealed class VatRateTable(IReadOnlyList<VatRate> rates)
{
    public decimal For(VatCategory category, DateOnly date) =>
        rates.Single(r => r.Category == category && r.ValidFrom <= date && (r.ValidTo is null || date < r.ValidTo)).Rate;
}

VatRateTable table = new(
[
    new(VatCategory.General, new DateOnly(2007, 12, 30), new DateOnly(2023, 7, 10), 0.18m),
    new(VatCategory.General, new DateOnly(2023, 7, 10), null, 0.20m),
    new(VatCategory.Reduced, new DateOnly(2007, 12, 30), new DateOnly(2023, 7, 10), 0.08m),
    new(VatCategory.Reduced, new DateOnly(2023, 7, 10), null, 0.10m),
    new(VatCategory.Basic, new DateOnly(2007, 12, 30), null, 0.01m),
]);
2023-07-09: general 0.18, reduced 0.08, basic 0.01
2023-07-10: general 0.20, reduced 0.10, basic 0.01
2026-11-07: general 0.20, reduced 0.10, basic 0.01

To keep the example short, the start date of the first rows is the Official Gazette date of decision 2007/13033; fill the real table from the history of the legislation. The interval is half-open (ValidFrom <= date < ValidTo), so July 10 doesn't fall into two rows. Choose the rate by the document's date and write the rate used onto the invoice line too; recalculating a past invoice from today's table when reprinting it means printing a pre-2023 invoice at 20 percent. The general pattern for effective-dated data is in the series post "Temporal Data: Effective-Dated Records, Price History and the Bitemporal Model". Which list a product or service falls into is a tax expert's decision; don't bury the category mapping in code, put it in data.

In the database, Money is a complex type: two columns, one value. The currency is converted with a value converter:

modelBuilder.Entity<BillingInvoice>().ToTable("Invoices").ComplexProperty(i => i.Total, money =>
{
    money.Property(x => x.Amount).HasColumnName("TotalAmount").HasPrecision(19, 4);
    money.Property(x => x.Currency).HasColumnName("TotalCurrency")
        .HasConversion(c => c.Code, code => Currency.FromCode(code))
        .HasMaxLength(3).IsUnicode(false).IsFixedLength();
});
CREATE TABLE [Invoices] (
    [Id] int NOT NULL IDENTITY,
    [LegacyAmount] decimal(18,2) NOT NULL,
    [TotalAmount] decimal(19,4) NOT NULL,
    [TotalCurrency] char(3) NOT NULL,
    CONSTRAINT [PK_Invoices] PRIMARY KEY ([Id])
);

LegacyAmount is a decimal property with no precision configured; EF Core made it decimal(18,2) and logged this warning:

No store type was specified for the decimal property 'LegacyAmount' on entity type 'BillingInvoice'. This will cause values to be silently truncated if they do not fit in the default precision and scale. Explicitly specify the SQL server column type that can accommodate all the values in 'OnModelCreating' using 'HasColumnType', specify precision and scale using 'HasPrecision', or configure a value converter using 'HasConversion'.

Write 6.666 to this column and you read back 6.67; the database did the rounding, not the application. Why (19,4)? Even if the stored amount is already a rounded number of cents, intermediate values multiplied by unit prices, exchange rates and rates need 4 decimals, and 19 digits keep overflow far away as scale grows. Storage precision (19,4) and display and document precision (the currency's 2 decimals) are separate decisions. In queries the complex type is transparent:

SELECT [i].[Id], [i].[LegacyAmount], [i].[TotalAmount], [i].[TotalCurrency]
FROM [Invoices] AS [i]
WHERE [i].[TotalCurrency] = 'TRY' AND [i].[TotalAmount] > 100.0

The last layer is JSON and the browser. JavaScript's number type is an IEEE 754 double; if the Angular side receives an amount as a number, it loses precision for large values, and if it adds amounts it hits the 0.1 + 0.2 problem:

0.1 + 0.2 (double) = 0.30000000000000004
as JavaScript number: 12345678901234568

That's why we use a converter that writes Money to JSON as a string amount together with its currency:

public override void Write(Utf8JsonWriter writer, Money value, JsonSerializerOptions options)
{
    writer.WriteStartObject();
    writer.WriteString("amount", value.Amount.ToString(CultureInfo.InvariantCulture));
    writer.WriteString("currency", value.Currency.Code);
    writer.WriteEndObject();
}
{"total":{"amount":"12345678901234567.89","currency":"TRY"},"rawTotal":12345678901234567.89}
round trip: 99.99 TRY

rawTotal shows that System.Text.Json writes the decimal exactly; the problem isn't on the server, it starts when the receiving side reads it as a double. The rule: the client formats money for display; it doesn't calculate it. Cart totals, VAT and installments come from the API. If the client really needs to calculate (an instant preview, say), the result is informational and the value that gets saved is the one the server calculated.

Currency conversion is a question of time too: which date's rate, which source's rate, buying or selling? Store the conversion result, the rate used, the rate's date and its source along with the document; when you regenerate a report a year later, don't try to find "that day's rate" again from outside.

Trade-off Table

CriterionA) decimal everywhereB) Money value objectC) Integer minor unitsD) SQL money
Data integrityCurrencies can get mixedCurrency is in the type; mixing throwsCurrency has to be carried separatelyNo currency
Code complexityStarts low, rules scatterMedium; one type, one ruleMedium; conversion at every boundaryLow
Rounding consistencyDepends on each point of useIn one place, explicit ruleFine for addition, not for multiplying by ratesPrecision loss in intermediate math
Distributing centsBy handAllocateInteger division + remainderBy hand
ReversibilityEasyEasy; same columnsRequires data conversionA type change needs a migration
Team cognitive loadLooks low, high error rateMediumMediumLow

When Not to Use It

A Money value object can be overkill in a small tool with a single currency and a single sum; there, decimal and one rounding helper are enough. In scientific or statistical calculations (average salary increase rate, a forecasting model), double is the right type; those values are ratios, not money. Don't round a pricing engine's intermediate calculations to cents; rounding happens once, only on the value written into the document. Don't write rules that automatically pick the VAT rate by product category in code; that mapping is data that changes with legislation. And don't choose the rounding rule or VAT calculation method on your own: decide together with the accounting team and your e-invoice integrator, and name the decision in code.

Checklist

  • Money is decimal, not double; the amount travels with its currency everywhere.
  • Adding different currencies fails at compile time or at runtime.
  • The rounding rule is chosen explicitly (MidpointRounding) and is the same in C# and SQL.
  • Allocate instead of division; installments and allocations add up to the original amount.
  • The sum of line VAT amounts equals the document's VAT; the method (per line or on the total) is agreed.
  • The VAT rate comes from an effective-dated table, chosen by the document's date and written onto the line.
  • The database uses decimal(19,4) and a separate currency column; there's no decimal property without precision.
  • The API sends amounts as strings; the client formats money, it doesn't calculate it.

This post builds on the series posts "Temporal Data: Effective-Dated Records, Price History and the Bitemporal Model" (the rate table) and "Sentinel Values: The Hidden Cost of 0, -1, 1900-01-01 and 9999-12-31" (NULL for an open-ended ValidTo); the amount of an invoice that has been numbered joins up with the previous post's topic. For the C# side see "Numbers and Arithmetic in C#: int, double, decimal and Overflow", "C# record and record struct: Value Equality and the with Expression" and "Operator Overloading and Indexers in C#", and for the EF Core mapping, "What's New in EF Core 8: Complex Types, Primitive Collections and SqlQuery<T>" and "Owned Types and Value Converters in EF Core: Addresses, Enums and Strongly Typed Ids". Sources: the Money pattern in Martin Fowler's "Patterns of Enterprise Application Architecture" catalog (amount and currency together, and allocate); on Microsoft Learn, Math.Round and MidpointRounding (the default being ToEven), the SQL Server "decimal and numeric", "money and smallmoney", "ROUND" and "Precision, scale, and length" docs, and the EF Core "Complex types" doc; the Turkish Revenue Administration's "Current VAT Rates" text (decision 2007/13033, Presidential Decision No. 7346, effective 10.07.2023); the Number type in the ECMAScript specification (IEEE 754 double).

A Small Experiment

★☆☆ Predict the results of Money.Try(0.10m).Allocate(1, 1, 1) and Money.Try(-100m).Allocate(1, 1, 1), then run them. Is the remainder distribution correct for a negative amount? If not, fix it.

★★☆ Build a mixed-rate invoice for the expense module: five lines with 1, 10 and 20 percent VAT. Compare the results of PerLine and FromTotal, and write down in a table how the per-rate totals will appear on the e-invoice document.

★★★ Design a ConvertedMoney type that stores the TL equivalent of an invoice issued in euros: source amount, target amount, rate, rate date and source. Map it as a complex type in EF Core and test that the same invoice gives the same TL amount when it's reprinted a year later.

Dates showed up twice in this post: we picked the VAT rate by the document's date and stored the exchange rate by the document's date. But what exactly is "the document's date"? In the next post we'll talk about time: UTC instants, local business dates, DateOnly and TimeProvider for a testable clock.