From 62c6c748eb68de66b89a11728e08c6f5fbb8f154 Mon Sep 17 00:00:00 2001 From: inflop Date: Wed, 6 May 2026 19:35:21 +0200 Subject: [PATCH] feat: add VAT rate symbol grouping and tests Implement VAT rate symbol grouping to distinguish between zero-rate categories ("0%", "ZW", "NP") in calculations. Enhance VatRate class with a Symbol property for value-object equality and update tests to verify correct behavior across different calculation methods. --- AGENTS.md | 2 +- README.md | 51 +++++ src/Inflop.VatSharp/ValueObjects/VatRate.cs | 114 +++++++--- .../Inflop.VatSharp.Tests/ValueObjectTests.cs | 129 +++++++++++ .../VatRateGroupingTests.cs | 208 ++++++++++++++++++ 5 files changed, 472 insertions(+), 32 deletions(-) create mode 100644 tests/Inflop.VatSharp.Tests/VatRateGroupingTests.cs diff --git a/AGENTS.md b/AGENTS.md index c1cee34..81c1ed4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,7 +250,7 @@ services.AddSingleton(VatCalculationRegistration.CreateEngine(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 diff --git a/README.md b/README.md index 596afe7..818708d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) @@ -189,6 +191,55 @@ var engine = VatCalculationEngine.ForItems(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. diff --git a/src/Inflop.VatSharp/ValueObjects/VatRate.cs b/src/Inflop.VatSharp/ValueObjects/VatRate.cs index c8723ff..685595e 100644 --- a/src/Inflop.VatSharp/ValueObjects/VatRate.cs +++ b/src/Inflop.VatSharp/ValueObjects/VatRate.cs @@ -1,23 +1,47 @@ namespace Inflop.VatSharp.ValueObjects; /// -/// 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 (<5%), zero (0%), and parking (≥12%) rates. /// The library accepts any rate 0–100% without enforcing category rules. +/// +/// +/// The property is part of value-object equality and serves as the grouping key +/// for 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 = 0. +/// +/// +/// +/// Implemented as a sealed record (reference type) rather than a readonly record struct +/// to prevent default(VatRate) from producing a null , bypassing +/// the factory invariant. default(VatRate) yields null, caught at compile time by +/// nullable reference type analysis. +/// /// -public readonly record struct VatRate : IComparable +public sealed record VatRate : IComparable { /// /// Percentage value (e.g. 23 for 23%). /// public decimal Percentage { get; } + /// + /// Invoice symbol identifying the VAT category (e.g. "23%", "8%", "0%", "ZW", "NP"). + /// Used as the grouping key for VAT rate summaries alongside . + /// 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. + /// + public string Symbol { get; } + /// /// Factory method to create a VAT rate from a decimal percentage value. - /// The provided percentage must be between 0 and 100 inclusive; otherwise, an is thrown. - /// The resulting instance will have the specified percentage value, which can be used for VAT calculations and comparisons. + /// defaults to the invariant-culture percentage string (e.g. "23%", "5.5%"). + /// Use to supply an explicit symbol such as "ZW" or "NP". /// /// /// The decimal percentage value representing the VAT rate (e.g. 23 for 23%). @@ -26,18 +50,41 @@ namespace Inflop.VatSharp.ValueObjects; /// /// Thrown when the provided percentage is not between 0 and 100 inclusive. /// - /// - /// A instance representing the specified VAT rate percentage. - /// 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}%")); + + /// + /// 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 row per art. 226 + /// pts 8–10 of Directive 2006/112/EC. + /// + /// + /// The decimal percentage value (e.g. 0 for zero / exempt / not-subject categories). + /// Must be between 0 and 100 inclusive. + /// + /// + /// The invoice symbol for this rate category (e.g. "ZW", "NP", "0%", "E", "AE"). + /// Must not be null or whitespace. + /// + /// + /// Thrown when is not between 0 and 100 inclusive. + /// + /// + /// Thrown when is null or whitespace. + /// + 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); + } /// /// Factory method to create a VAT rate from an integer percentage value. - /// The provided percentage must be between 0 and 100 inclusive; otherwise, an is thrown. - /// The resulting instance will have the specified percentage value, which can be used for VAT calculations and comparisons. + /// defaults to the percentage string (e.g. "23%"). /// /// /// The integer percentage value representing the VAT rate (e.g. 23 for 23%). @@ -46,9 +93,6 @@ public static VatRate Of(decimal percentage) /// /// Thrown when the provided percentage is not between 0 and 100 inclusive. /// - /// - /// A instance representing the specified VAT rate percentage. - /// public static VatRate Of(int percentage) => Of((decimal)percentage); @@ -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 . /// - public static readonly VatRate Zero = new(0m); + public static readonly VatRate Zero = new(0m, "0%"); /// - /// 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 . + /// 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). /// public bool IsZero - => Percentage == Zero.Percentage; + => Percentage == 0m; /// /// Calculates the VAT amount from a net price using this VAT rate. @@ -85,7 +130,7 @@ public bool IsZero /// The net price (excluding VAT) from which to calculate the VAT amount. /// /// - /// 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 . /// public Money VatFromNet(Money net) @@ -96,14 +141,13 @@ public Money VatFromNet(Money net) /// /// /// 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 . /// /// /// The gross price (including VAT) from which to calculate the VAT amount. /// /// - /// 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 . /// public Money VatFromGross(Money gross) @@ -125,7 +169,7 @@ public Money VatFromGross(Money gross) /// The net price (excluding VAT) from which to calculate the gross price. /// /// - /// 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 . /// public Money GrossFromNet(Money net) @@ -136,27 +180,35 @@ public Money GrossFromNet(Money net) /// /// /// The formula used is: net = gross - VAT(gross) = gross / (1 + rate / 100). + /// The result is intentionally unrounded — the caller applies the . /// /// /// The gross price (including VAT) from which to calculate the net price. /// /// - /// 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 . /// public Money NetFromGross(Money gross) => Money.Raw(gross.Value - VatFromGross(gross).Value); /// - 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); + } /// - /// Returns a string representation of the VAT rate as a percentage followed by the percent sign (e.g. "23%" or "5.5%"). + /// Returns the (e.g. "23%", "ZW", "NP"). /// public override string ToString() - => $"{Percentage}%"; + => Symbol; - private VatRate(decimal percentage) - => Percentage = percentage; + private VatRate(decimal percentage, string symbol) + { + Percentage = percentage; + Symbol = symbol; + } } diff --git a/tests/Inflop.VatSharp.Tests/ValueObjectTests.cs b/tests/Inflop.VatSharp.Tests/ValueObjectTests.cs index 80298a7..f1d7e6f 100644 --- a/tests/Inflop.VatSharp.Tests/ValueObjectTests.cs +++ b/tests/Inflop.VatSharp.Tests/ValueObjectTests.cs @@ -153,6 +153,135 @@ public void GrossFromNet_AndNetFromGross_AreInverse() var net = rate.NetFromGross(gross); net.Value.Should().Be(original.Value); } + + // ── Symbol property and equality semantics ────────────────────────────── + + [Fact] + public void Of_WithSymbol_SetsSymbol() + { + // Explicit symbol overload preserves the supplied label verbatim + VatRate.Of(0m, "ZW").Symbol.Should().Be("ZW"); + } + + [Theory] + [InlineData(23, "23%")] + [InlineData(8, "8%")] + [InlineData(0, "0%")] + public void Of_WithoutSymbol_DefaultsToPercentageString(int percentage, string expectedSymbol) + { + // Default symbol = invariant-culture "{percentage}%" formatting + VatRate.Of((decimal)percentage).Symbol.Should().Be(expectedSymbol); + } + + [Fact] + public void Of_WithoutSymbol_DefaultsToInvariantDecimalPercentageString() + { + // 5.5m formatted invariant-culture is "5.5%" (decimal point, never comma) + VatRate.Of(5.5m).Symbol.Should().Be("5.5%"); + } + + [Fact] + public void Of_Zero_HasZeroPercentSymbol() + { + // VatRate.Zero is defined as new(0m, "0%") + VatRate.Zero.Symbol.Should().Be("0%"); + } + + [Fact] + public void Of_ZW_NotEqualTo_NP() + { + // Both have Percentage=0 but different Symbol → distinct value-object identity + // → separate VatRateSummary rows in JPK_V7 / art. 226 pts 8–10 reporting + var zw = VatRate.Of(0m, "ZW"); + var np = VatRate.Of(0m, "NP"); + zw.Should().NotBe(np); + (zw == np).Should().BeFalse(); + } + + [Fact] + public void Of_ZW_NotEqualTo_DefaultZero() + { + // ZW (exempt, art. 43 ustawy o VAT) is legally distinct from 0% (zero-rated, art. 83) + // Even though Percentage matches (=0), Symbol differs → not equal + VatRate.Of(0m, "ZW").Should().NotBe(VatRate.Of(0m)); + } + + [Fact] + public void Of_SamePercentageAndSymbol_AreEqual() + { + // Structural equality: same Percentage AND same Symbol → equal + var a = VatRate.Of(0m, "ZW"); + var b = VatRate.Of(0m, "ZW"); + a.Should().Be(b); + (a == b).Should().BeTrue(); + } + + [Fact] + public void Of_ZeroOf_EqualsVatRateZero() + { + // VatRate.Of(0m) defaults Symbol to "0%" — same as VatRate.Zero + VatRate.Of(0m).Should().Be(VatRate.Zero); + (VatRate.Of(0m) == VatRate.Zero).Should().BeTrue(); + } + + [Fact] + public void Of_IntWithoutSymbol_DefaultsToPercentageString() + { + // int overload delegates to Of(decimal) → same default symbol formatting + VatRate.Of(23).Symbol.Should().Be("23%"); + } + + [Fact] + public void ToString_ReturnsSymbol() + { + // ToString() returns Symbol verbatim (not "{Percentage}%" anymore) + VatRate.Of(0m, "ZW").ToString().Should().Be("ZW"); + VatRate.Of(23).ToString().Should().Be("23%"); + } + + [Theory] + [InlineData("ZW")] + [InlineData("NP")] + [InlineData("0%")] + public void IsZero_ZeroPercentage_RegardlessOfSymbol(string symbol) + { + // IsZero is driven solely by Percentage == 0m; Symbol is irrelevant + VatRate.Of(0m, symbol).IsZero.Should().BeTrue(); + } + + [Fact] + public void CompareTo_SamePercentage_OrdersBySymbolAlphabetically() + { + // When Percentage ties, ordinal Symbol compare breaks the tie: 'N' < 'Z' → < 0 + VatRate.Of(0m, "NP").CompareTo(VatRate.Of(0m, "ZW")).Should().BeLessThan(0); + VatRate.Of(0m, "ZW").CompareTo(VatRate.Of(0m, "NP")).Should().BeGreaterThan(0); + } + + [Fact] + public void CompareTo_DifferentPercentage_OrdersByPercentageFirst() + { + // Primary key is Percentage: 0% < 23% even when Symbol of 0% sorts later alphabetically + VatRate.Of(0m, "ZW").CompareTo(VatRate.Of(23)).Should().BeLessThan(0); + } + + [Fact] + public void Of_NullSymbol_Throws() + { + // ArgumentException.ThrowIfNullOrWhiteSpace(symbol) surfaces null as ArgumentException + FluentActions.Invoking(() => VatRate.Of(0m, null!)) + .Should().Throw(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void Of_WhitespaceSymbol_Throws(string symbol) + { + // Whitespace / empty symbols are rejected — invoice symbol must be a meaningful label + FluentActions.Invoking(() => VatRate.Of(0m, symbol)) + .Should().Throw(); + } } public class DefaultRoundingTests diff --git a/tests/Inflop.VatSharp.Tests/VatRateGroupingTests.cs b/tests/Inflop.VatSharp.Tests/VatRateGroupingTests.cs new file mode 100644 index 0000000..710ae42 --- /dev/null +++ b/tests/Inflop.VatSharp.Tests/VatRateGroupingTests.cs @@ -0,0 +1,208 @@ +using FluentAssertions; +using Inflop.VatSharp.Enums; +using Inflop.VatSharp.ValueObjects; +using Xunit; + +namespace Inflop.VatSharp.Tests; + +/// +/// Verifies that grouping in all calculation strategies treats +/// distinct zero-rate symbols ("0%", "ZW", "NP") as separate rows. +/// +/// Legal basis: art. 226 pts 8–10 of Directive 2006/112/EC and Polish JPK_V7 reporting +/// require legally distinct categories — zero-rated (art. 83 ustawy o VAT), exempt (art. 43), +/// and not-subject (NP / reverse charge) — to be reported separately even though all share +/// Percentage = 0. +/// +public class VatRateGroupingTests +{ + // ── FromSumOfNetValues (Method I) ─────────────────────────────────────── + + [Fact] + public void Calculate_FromSumOfNetValues_DistinctZeroSymbols_ProduceSeparateSummaries() + { + // Three legally distinct zero-rate categories on one document, plus one 23% line: + // "0%" → 100 net, no VAT, gross 100 + // "ZW" → 200 net, no VAT, gross 200 + // "NP" → 300 net, no VAT, gross 300 + // "23%" → 1000 net, VAT 230, gross 1230 + // Expected: 4 distinct VatRateSummary rows (NOT 1 collapsed zero-rate row) + var engine = VatCalculationEngine.Create(); + var items = new InvoiceLineItem[] + { + new(UnitPrice.Net(100m), Quantity.One, VatRate.Of(0m, "0%")), + new(UnitPrice.Net(200m), Quantity.One, VatRate.Of(0m, "ZW")), + new(UnitPrice.Net(300m), Quantity.One, VatRate.Of(0m, "NP")), + new(UnitPrice.Net(1000m), Quantity.One, VatRate.Of(23)), + }; + + var result = engine.Calculate(items, VatCalculationMethod.FromSumOfNetValues); + + result.VatRateSummaries.Should().HaveCount(4); + + var zeroRated = result.VatRateSummaries.Single(s => s.VatRate.Symbol == "0%"); + zeroRated.TotalNet.Value.Should().Be(100m); + zeroRated.TotalVat.Value.Should().Be(0m); + zeroRated.TotalGross.Value.Should().Be(100m); + + var exempt = result.VatRateSummaries.Single(s => s.VatRate.Symbol == "ZW"); + exempt.TotalNet.Value.Should().Be(200m); + exempt.TotalVat.Value.Should().Be(0m); + exempt.TotalGross.Value.Should().Be(200m); + + var notSubject = result.VatRateSummaries.Single(s => s.VatRate.Symbol == "NP"); + notSubject.TotalNet.Value.Should().Be(300m); + notSubject.TotalVat.Value.Should().Be(0m); + notSubject.TotalGross.Value.Should().Be(300m); + + var standard = result.VatRateSummaries.Single(s => s.VatRate.Symbol == "23%"); + standard.TotalNet.Value.Should().Be(1000m); + standard.TotalVat.Value.Should().Be(230m); // 1000 × 23% = 230 + standard.TotalGross.Value.Should().Be(1230m); // 1000 + 230 + + // Document-level aggregates: 100 + 200 + 300 + 1000 = 1600 net, only the 23% line carries VAT + result.TotalNet.Value.Should().Be(1600m); + result.TotalVat.Value.Should().Be(230m); + result.TotalGross.Value.Should().Be(1830m); + } + + // ── FromSumOfGrossValues (Method II) ──────────────────────────────────── + + [Fact] + public void Calculate_FromSumOfGrossValues_DistinctZeroSymbols_ProduceSeparateSummaries() + { + // Strategy II requires UnitPrice.Gross() for every line. + // Zero-rate lines: gross == net (no VAT), so gross prices match the net targets: + // "0%" → gross 100 → net 100, VAT 0 + // "ZW" → gross 200 → net 200, VAT 0 + // "NP" → gross 300 → net 300, VAT 0 + // "23%" → gross 1230 → VAT = 1230 × 23/123 = 230, net = 1000 + var engine = VatCalculationEngine.Create(); + var items = new InvoiceLineItem[] + { + new(UnitPrice.Gross(100m), Quantity.One, VatRate.Of(0m, "0%")), + new(UnitPrice.Gross(200m), Quantity.One, VatRate.Of(0m, "ZW")), + new(UnitPrice.Gross(300m), Quantity.One, VatRate.Of(0m, "NP")), + new(UnitPrice.Gross(1230m), Quantity.One, VatRate.Of(23)), + }; + + var result = engine.Calculate(items, VatCalculationMethod.FromSumOfGrossValues); + + result.VatRateSummaries.Should().HaveCount(4); + + var zeroRated = result.VatRateSummaries.Single(s => s.VatRate.Symbol == "0%"); + zeroRated.TotalNet.Value.Should().Be(100m); + zeroRated.TotalVat.Value.Should().Be(0m); + zeroRated.TotalGross.Value.Should().Be(100m); + + var exempt = result.VatRateSummaries.Single(s => s.VatRate.Symbol == "ZW"); + exempt.TotalNet.Value.Should().Be(200m); + exempt.TotalVat.Value.Should().Be(0m); + exempt.TotalGross.Value.Should().Be(200m); + + var notSubject = result.VatRateSummaries.Single(s => s.VatRate.Symbol == "NP"); + notSubject.TotalNet.Value.Should().Be(300m); + notSubject.TotalVat.Value.Should().Be(0m); + notSubject.TotalGross.Value.Should().Be(300m); + + var standard = result.VatRateSummaries.Single(s => s.VatRate.Symbol == "23%"); + standard.TotalGross.Value.Should().Be(1230m); + standard.TotalVat.Value.Should().Be(230m); // 1230 × 23/123 = 230 (exact) + standard.TotalNet.Value.Should().Be(1000m); // 1230 − 230 + + // Aggregates: 100 + 200 + 300 + 1000 = 1600 net, 230 VAT, 1830 gross + result.TotalNet.Value.Should().Be(1600m); + result.TotalVat.Value.Should().Be(230m); + result.TotalGross.Value.Should().Be(1830m); + } + + // ── SumOfLineItemVatAmounts (Method III) ──────────────────────────────── + + [Fact] + public void Calculate_SumOfLineItemVatAmounts_DistinctZeroSymbols_ProduceSeparateSummaries() + { + // Method III rounds VAT per line then sums; for zero-rate lines per-line VAT = 0, + // so totals match Method I exactly. The 23% line contributes 230 VAT (100 × 23%). + var engine = VatCalculationEngine.Create(); + var items = new InvoiceLineItem[] + { + new(UnitPrice.Net(100m), Quantity.One, VatRate.Of(0m, "0%")), + new(UnitPrice.Net(200m), Quantity.One, VatRate.Of(0m, "ZW")), + new(UnitPrice.Net(300m), Quantity.One, VatRate.Of(0m, "NP")), + new(UnitPrice.Net(1000m), Quantity.One, VatRate.Of(23)), + }; + + var result = engine.Calculate(items, VatCalculationMethod.SumOfLineItemVatAmounts); + + result.VatRateSummaries.Should().HaveCount(4); + + var zeroRated = result.VatRateSummaries.Single(s => s.VatRate.Symbol == "0%"); + zeroRated.TotalNet.Value.Should().Be(100m); + zeroRated.TotalVat.Value.Should().Be(0m); + zeroRated.TotalGross.Value.Should().Be(100m); + + var exempt = result.VatRateSummaries.Single(s => s.VatRate.Symbol == "ZW"); + exempt.TotalNet.Value.Should().Be(200m); + exempt.TotalVat.Value.Should().Be(0m); + exempt.TotalGross.Value.Should().Be(200m); + + var notSubject = result.VatRateSummaries.Single(s => s.VatRate.Symbol == "NP"); + notSubject.TotalNet.Value.Should().Be(300m); + notSubject.TotalVat.Value.Should().Be(0m); + notSubject.TotalGross.Value.Should().Be(300m); + + var standard = result.VatRateSummaries.Single(s => s.VatRate.Symbol == "23%"); + standard.TotalNet.Value.Should().Be(1000m); + standard.TotalVat.Value.Should().Be(230m); // 1000 × 23% = 230 (exact, no rounding) + standard.TotalGross.Value.Should().Be(1230m); + + result.TotalNet.Value.Should().Be(1600m); + result.TotalVat.Value.Should().Be(230m); + result.TotalGross.Value.Should().Be(1830m); + } + + // ── Symbol preservation regression ────────────────────────────────────── + + [Theory] + [InlineData(VatCalculationMethod.FromSumOfNetValues)] + [InlineData(VatCalculationMethod.SumOfLineItemVatAmounts)] + public void Calculate_NetMethods_PreservesSymbolOnVatRateSummary(VatCalculationMethod method) + { + // Regression: the Symbol on grouped VatRateSummary.VatRate must match the input Symbol + // verbatim. Useful for downstream JPK_V7 / e-invoice serialization that keys on Symbol. + var engine = VatCalculationEngine.Create(); + var items = new InvoiceLineItem[] + { + new(UnitPrice.Net(100m), Quantity.One, VatRate.Of(0m, "0%")), + new(UnitPrice.Net(200m), Quantity.One, VatRate.Of(0m, "ZW")), + new(UnitPrice.Net(300m), Quantity.One, VatRate.Of(0m, "NP")), + }; + + var result = engine.Calculate(items, method); + + // Symbol-keyed lookup must return the exact net value supplied for that category + result.VatRateSummaries.Single(s => s.VatRate.Symbol == "ZW").TotalNet.Value.Should().Be(200m); + result.VatRateSummaries.Single(s => s.VatRate.Symbol == "NP").TotalNet.Value.Should().Be(300m); + result.VatRateSummaries.Single(s => s.VatRate.Symbol == "0%").TotalNet.Value.Should().Be(100m); + } + + [Fact] + public void Calculate_GrossMethod_PreservesSymbolOnVatRateSummary() + { + // Same regression for gross-priced inputs (Method II requires UnitPrice.Gross()). + // Zero-rate lines: gross == net, so gross input matches the expected per-symbol net total. + var engine = VatCalculationEngine.Create(); + var items = new InvoiceLineItem[] + { + new(UnitPrice.Gross(100m), Quantity.One, VatRate.Of(0m, "0%")), + new(UnitPrice.Gross(200m), Quantity.One, VatRate.Of(0m, "ZW")), + new(UnitPrice.Gross(300m), Quantity.One, VatRate.Of(0m, "NP")), + }; + + var result = engine.Calculate(items, VatCalculationMethod.FromSumOfGrossValues); + + result.VatRateSummaries.Single(s => s.VatRate.Symbol == "ZW").TotalNet.Value.Should().Be(200m); + result.VatRateSummaries.Single(s => s.VatRate.Symbol == "NP").TotalNet.Value.Should().Be(300m); + result.VatRateSummaries.Single(s => s.VatRate.Symbol == "0%").TotalNet.Value.Should().Be(100m); + } +}