Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ services.AddSingleton(VatCalculationRegistration.CreateEngine<TDoc, TLine>(cfg =
## Key Conventions

- All public result types are immutable; use `readonly record struct` for new value objects
- **Exception**: `Quantity` and `CurrencyCode` are `sealed record` (reference type), NOT `readonly record struct`. This is intentional — a struct would expose an implicit parameterless constructor allowing `default(T)` to bypass the positive-value / non-empty invariant. `static One` / `static PLN` returning `new(...)` instead of `static readonly` is accepted: `default(sealed record)` is `null`, not a zero-value instance.
- **Exception**: `Quantity`, `CurrencyCode`, and `VatRate` are `sealed record` (reference type), NOT `readonly record struct`. This is intentional — a struct would expose an implicit parameterless constructor allowing `default(T)` to bypass invariants (`Quantity` positive-value, `CurrencyCode` non-empty, `VatRate` non-null `Symbol`). `static One` / `static PLN` / `static Zero` returning `new(...)` instead of `static readonly` is accepted: `default(sealed record)` is `null`, not a zero-value instance.
- Validation uses `ArgumentNullException.ThrowIfNull()` and `ArgumentOutOfRangeException`
- Strategies are stateless — they can and should be singletons
- The library has no currency handling by design; rounding strategy controls precision
Expand Down
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ EU VAT calculation library compliant with **Council Directive 2006/112/EC** —
## Features

- **Three calculation methods** — net-sum, gross-sum, and per-line VAT (art. 226 of Directive 2006/112/EC)
- **Per-symbol VAT category grouping** — separate summary rows for "0%", "ZW", "NP" and PEPPOL codes (art. 226)
- **Zero-friction integration** — fluent mapping engine binds the library to any existing domain model without interfaces, attributes, or inheritance
- **Foreign currency** — dual-currency results for VAT declarations (art. 91), with per-strategy base-currency conversion
- **Discounts** — percentage and absolute, at line level (art. 79 lit. b); configurable per-unit vs. from-total behavior
Expand All @@ -28,6 +29,7 @@ EU VAT calculation library compliant with **Council Directive 2006/112/EC** —
- [Quick start](#quick-start)
- [Fluent mapping engine](#fluent-mapping-engine)
- [Discounts](#discounts)
- [VAT rate symbols and zero-rate categories](#vat-rate-symbols-and-zero-rate-categories)
- [Foreign currency](#foreign-currency)
- [Custom rounding](#custom-rounding)
- [Dependency injection](#dependency-injection)
Expand Down Expand Up @@ -189,6 +191,55 @@ var engine = VatCalculationEngine.ForItems<Line>(cfg => cfg
// .Discount(x => x.Disc) // Discount? — absolute or percentage
```

## VAT rate symbols and zero-rate categories

Art. 226 pts 8–10 of Directive 2006/112/EC require an invoice to disclose the taxable amount, the rate, and the VAT amount **per rate category**. Several legally distinct categories share the same numerical rate of 0 % — for example Polish "0 %" (zero-rated, art. 83 ustawy o VAT), "ZW" (exempt, art. 43), and "NP" (not subject to VAT / reverse charge) — and must appear as **separate rows** on the invoice and in the JPK_V7M file.

`VatRate.Symbol` is the grouping key for `VatRateSummary` rows. It is part of value-object equality, so two zero-percentage rates with different symbols produce two distinct summary rows.

### Creating a rate with an explicit symbol

```csharp
VatRate standard = VatRate.Of(23); // Symbol = "23%" (auto-generated)
VatRate zeroRated = VatRate.Zero; // Symbol = "0%"
VatRate exempt = VatRate.Of(0, "ZW"); // Polish exempt — art. 43 ustawy o VAT
VatRate notSubject = VatRate.Of(0, "NP"); // not subject — reverse charge / out of scope
VatRate peppolExempt = VatRate.Of(0, "E"); // PEPPOL Tax Category Code: Exempt
VatRate peppolReverse = VatRate.Of(0, "AE"); // PEPPOL: VAT Reverse Charge
```

`VatRate.IsZero` returns `true` for **all** zero-percentage rates regardless of symbol — useful when deciding whether to skip VAT arithmetic.

### Separate summary rows per symbol

```csharp
using Inflop.VatSharp;
using Inflop.VatSharp.Enums;
using Inflop.VatSharp.ValueObjects;

var engine = VatCalculationEngine.Create();

var items = new[]
{
new InvoiceLineItem(UnitPrice.Net(500.00m), Quantity.Of(1), VatRate.Of(23)),
new InvoiceLineItem(UnitPrice.Net(100.00m), Quantity.Of(1), VatRate.Zero), // "0%"
new InvoiceLineItem(UnitPrice.Net(200.00m), Quantity.Of(1), VatRate.Of(0, "ZW")), // exempt
new InvoiceLineItem(UnitPrice.Net(300.00m), Quantity.Of(1), VatRate.Of(0, "NP")), // not subject
};

DocumentAmounts result = engine.Calculate(items, VatCalculationMethod.FromSumOfNetValues);

// result.VatRateSummaries — one row per (Percentage, Symbol), in input order:
// [0] Symbol "23%" TotalNet 500.00 TotalVat 115.00 TotalGross 615.00
// [1] Symbol "0%" TotalNet 100.00 TotalVat 0.00 TotalGross 100.00
// [2] Symbol "ZW" TotalNet 200.00 TotalVat 0.00 TotalGross 200.00
// [3] Symbol "NP" TotalNet 300.00 TotalVat 0.00 TotalGross 300.00
//
// Document totals: Net 1100.00, VAT 115.00, Gross 1215.00
```

The four zero-rate categories remain distinguishable in `VatRateSummaries` — exactly what JPK_V7M and the EU VAT Directive require for invoice disclosure.

## Foreign currency

For invoices denominated in a foreign currency, the library produces amounts in both the invoice currency and the base (settlement) currency required for VAT declarations — e.g. PLN in Poland, EUR for euro-area countries. Legal basis: art. 91 of Directive 2006/112/EC.
Expand Down
114 changes: 83 additions & 31 deletions src/Inflop.VatSharp/ValueObjects/VatRate.cs
Original file line number Diff line number Diff line change
@@ -1,23 +1,47 @@
namespace Inflop.VatSharp.ValueObjects;

/// <summary>
/// VAT rate as a percentage (e.g. 23 for 23%).
/// VAT rate as a percentage (e.g. 23 for 23%) together with its invoice symbol (e.g. "23%", "ZW", "NP").
///
/// EU Directive 2006/112/EC allows standard (≥15%), reduced (≥5%),
/// super-reduced (&lt;5%), zero (0%), and parking (≥12%) rates.
/// The library accepts any rate 0–100% without enforcing category rules.
///
/// <para>
/// The <see cref="Symbol"/> property is part of value-object equality and serves as the grouping key
/// for <see cref="VatRateSummary"/> rows (art. 226 pts 8–10 of Directive 2006/112/EC).
/// This correctly separates legally distinct zero-rate categories — such as Polish "0%" (zero-rated,
/// art. 83 ustawy o VAT), "ZW" (exempt, art. 43 ustawy o VAT), and "NP" (not subject to VAT /
/// reverse charge) — into separate summary rows even though all share <see cref="Percentage"/> = 0.
/// </para>
///
/// <para>
/// Implemented as a <c>sealed record</c> (reference type) rather than a <c>readonly record struct</c>
/// to prevent <c>default(VatRate)</c> from producing a <c>null</c> <see cref="Symbol"/>, bypassing
/// the factory invariant. <c>default(VatRate)</c> yields <c>null</c>, caught at compile time by
/// nullable reference type analysis.
/// </para>
/// </summary>
public readonly record struct VatRate : IComparable<VatRate>
public sealed record VatRate : IComparable<VatRate>
{
/// <summary>
/// Percentage value (e.g. 23 for 23%).
/// </summary>
public decimal Percentage { get; }

/// <summary>
/// Invoice symbol identifying the VAT category (e.g. "23%", "8%", "0%", "ZW", "NP").
/// Used as the grouping key for VAT rate summaries alongside <see cref="Percentage"/>.
/// Defaults to the percentage string (e.g. "23%") when not specified explicitly.
/// Country-neutral: use national conventions ("ZW", "NP") or PEPPOL Tax Category Codes
/// ("E", "AE", "O") as appropriate.
/// </summary>
public string Symbol { get; }

/// <summary>
/// Factory method to create a VAT rate from a decimal percentage value.
/// The provided percentage must be between 0 and 100 inclusive; otherwise, an <see cref="ArgumentOutOfRangeException"/> is thrown.
/// The resulting <see cref="VatRate"/> instance will have the specified percentage value, which can be used for VAT calculations and comparisons.
/// <see cref="Symbol"/> defaults to the invariant-culture percentage string (e.g. "23%", "5.5%").
/// Use <see cref="Of(decimal, string)"/> to supply an explicit symbol such as "ZW" or "NP".
/// </summary>
/// <param name="percentage">
/// The decimal percentage value representing the VAT rate (e.g. 23 for 23%).
Expand All @@ -26,18 +50,41 @@ namespace Inflop.VatSharp.ValueObjects;
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when the provided percentage is not between 0 and 100 inclusive.
/// </exception>
/// <returns>
/// A <see cref="VatRate"/> instance representing the specified VAT rate percentage.
/// </returns>
public static VatRate Of(decimal percentage)
=> percentage is >= 0 and <= 100
? new(percentage)
: throw new ArgumentOutOfRangeException(nameof(percentage), $"VAT rate must be 0–100%: {percentage}.");
=> Of(percentage, FormattableString.Invariant($"{percentage}%"));

/// <summary>
/// Factory method to create a VAT rate from a decimal percentage value and an explicit symbol.
/// Use this overload to distinguish legally distinct zero-rate categories such as
/// "0%" (zero-rated), "ZW" (exempt — art. 43 ustawy o VAT), or "NP" (not subject to VAT).
/// Each unique symbol produces a separate <see cref="VatRateSummary"/> row per art. 226
/// pts 8–10 of Directive 2006/112/EC.
/// </summary>
/// <param name="percentage">
/// The decimal percentage value (e.g. 0 for zero / exempt / not-subject categories).
/// Must be between 0 and 100 inclusive.
/// </param>
/// <param name="symbol">
/// The invoice symbol for this rate category (e.g. "ZW", "NP", "0%", "E", "AE").
/// Must not be null or whitespace.
/// </param>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="percentage"/> is not between 0 and 100 inclusive.
/// </exception>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="symbol"/> is null or whitespace.
/// </exception>
public static VatRate Of(decimal percentage, string symbol)
{
if (percentage is < 0 or > 100)
throw new ArgumentOutOfRangeException(nameof(percentage), $"VAT rate must be 0–100%: {percentage}.");
ArgumentException.ThrowIfNullOrWhiteSpace(symbol);
return new(percentage, symbol);
}

/// <summary>
/// Factory method to create a VAT rate from an integer percentage value.
/// The provided percentage must be between 0 and 100 inclusive; otherwise, an <see cref="ArgumentOutOfRangeException"/> is thrown.
/// The resulting <see cref="VatRate"/> instance will have the specified percentage value, which can be used for VAT calculations and comparisons.
/// <see cref="Symbol"/> defaults to the percentage string (e.g. "23%").
/// </summary>
/// <param name="percentage">
/// The integer percentage value representing the VAT rate (e.g. 23 for 23%).
Expand All @@ -46,9 +93,6 @@ public static VatRate Of(decimal percentage)
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when the provided percentage is not between 0 and 100 inclusive.
/// </exception>
/// <returns>
/// A <see cref="VatRate"/> instance representing the specified VAT rate percentage.
/// </returns>
public static VatRate Of(int percentage)
=> Of((decimal)percentage);

Expand All @@ -63,16 +107,17 @@ public decimal Multiplier
/// per art. 169(a) of Directive 2006/112/EC.
/// Typical for intra-Community supplies (art. 138) and exports (art. 146).
/// Not to be confused with exempt supplies (art. 132–136), which carry no right to deduct.
/// Symbol is "0%". For Polish "ZW" or "NP" use <see cref="Of(decimal, string)"/>.
/// </summary>
public static readonly VatRate Zero = new(0m);
public static readonly VatRate Zero = new(0m, "0%");

/// <summary>
/// Returns true when the VAT rate is exactly zero.
/// Note that this is not the same as being close to zero — a very small non-zero rate will return false.
/// Zero-rated supplies are taxable at 0%; input VAT is deductible (art. 169(a) Directive 2006/112/EC).
/// Returns true when the VAT rate percentage is exactly zero, regardless of <see cref="Symbol"/>.
/// Applies to all zero-percentage categories including "0%", "ZW", and "NP".
/// Zero-rated supplies (0%) are taxable at 0%; input VAT is deductible (art. 169(a) Directive 2006/112/EC).
/// </summary>
public bool IsZero
=> Percentage == Zero.Percentage;
=> Percentage == 0m;

/// <summary>
/// Calculates the VAT amount from a net price using this VAT rate.
Expand All @@ -85,7 +130,7 @@ public bool IsZero
/// The net price (excluding VAT) from which to calculate the VAT amount.
/// </param>
/// <returns>
/// The VAT amount calculated from the net price. This is the portion of the net price that corresponds to VAT.
/// The VAT amount calculated from the net price.
/// The result is intentionally unrounded — the caller applies the <see cref="Strategies.Rounding.IRoundingStrategy"/>.
/// </returns>
public Money VatFromNet(Money net)
Expand All @@ -96,14 +141,13 @@ public Money VatFromNet(Money net)
/// </summary>
/// <remarks>
/// The formula used is: VAT = gross × (rate / (100 + rate)).
/// This formula derives from the relationship gross = net + VAT, where net = gross - VAT.
/// The result is intentionally unrounded — the caller applies the <see cref="Strategies.Rounding.IRoundingStrategy"/>.
/// </remarks>
/// <param name="gross">
/// The gross price (including VAT) from which to calculate the VAT amount.
/// </param>
/// <returns>
/// The VAT amount calculated from the gross price. This is the portion of the gross price that corresponds to VAT.
/// The VAT amount calculated from the gross price.
/// The result is intentionally unrounded — the caller applies the <see cref="Strategies.Rounding.IRoundingStrategy"/>.
/// </returns>
public Money VatFromGross(Money gross)
Expand All @@ -125,7 +169,7 @@ public Money VatFromGross(Money gross)
/// The net price (excluding VAT) from which to calculate the gross price.
/// </param>
/// <returns>
/// The gross price calculated from the net price. This is the total price including VAT.
/// The gross price calculated from the net price.
/// The result is intentionally unrounded — the caller applies the <see cref="Strategies.Rounding.IRoundingStrategy"/>.
/// </returns>
public Money GrossFromNet(Money net)
Expand All @@ -136,27 +180,35 @@ public Money GrossFromNet(Money net)
/// </summary>
/// <remarks>
/// The formula used is: net = gross - VAT(gross) = gross / (1 + rate / 100).
/// The result is intentionally unrounded — the caller applies the <see cref="Strategies.Rounding.IRoundingStrategy"/>.
/// </remarks>
/// <param name="gross">
/// The gross price (including VAT) from which to calculate the net price.
/// </param>
/// <returns>
/// The net price calculated from the gross price. This is the price excluding VAT.
/// The net price calculated from the gross price.
/// The result is intentionally unrounded — the caller applies the <see cref="Strategies.Rounding.IRoundingStrategy"/>.
/// </returns>
public Money NetFromGross(Money gross)
=> Money.Raw(gross.Value - VatFromGross(gross).Value);

/// <inheritdoc />
public int CompareTo(VatRate other)
=> Percentage.CompareTo(other.Percentage);
public int CompareTo(VatRate? other)
{
if (other is null) return 1;
var byPercentage = Percentage.CompareTo(other.Percentage);
return byPercentage != 0 ? byPercentage : string.Compare(Symbol, other.Symbol, StringComparison.Ordinal);
}

/// <summary>
/// Returns a string representation of the VAT rate as a percentage followed by the percent sign (e.g. "23%" or "5.5%").
/// Returns the <see cref="Symbol"/> (e.g. "23%", "ZW", "NP").
/// </summary>
public override string ToString()
=> $"{Percentage}%";
=> Symbol;

private VatRate(decimal percentage)
=> Percentage = percentage;
private VatRate(decimal percentage, string symbol)
{
Percentage = percentage;
Symbol = symbol;
}
}
Loading
Loading