diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4acb363 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,247 @@ +# Remove the line below if you want to inherit .editorconfig settings from higher directories +root = true + +# All files +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 4 + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +indent_style = space +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = true + +#### .NET Coding Conventions #### + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = true +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_method = false +dotnet_style_qualification_for_property = false + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true +dotnet_style_predefined_type_for_member_access = true + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_operators = never_if_unnecessary +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members + +# Expression-level preferences +dotnet_style_coalesce_expression = true +dotnet_style_collection_initializer = true +dotnet_style_explicit_tuple_names = true +dotnet_style_namespace_match_folder = true +dotnet_style_null_propagation = true +dotnet_style_object_initializer = true +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true +dotnet_style_prefer_compound_assignment = true +dotnet_style_prefer_conditional_expression_over_assignment = true +dotnet_style_prefer_conditional_expression_over_return = true +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed +dotnet_style_prefer_inferred_anonymous_type_member_names = true +dotnet_style_prefer_inferred_tuple_names = true +dotnet_style_prefer_is_null_check_over_reference_equality_method = true +dotnet_style_prefer_simplified_boolean_expressions = true +dotnet_style_prefer_simplified_interpolation = true + +# Field preferences +dotnet_style_readonly_field = true + +# Parameter preferences +dotnet_code_quality_unused_parameters = all + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = 0 + +# New line preferences +dotnet_style_allow_multiple_blank_lines_experimental = true +dotnet_style_allow_statement_immediately_after_block_experimental = true + +#### C# Coding Conventions #### + +# var preferences +csharp_style_var_elsewhere = false +csharp_style_var_for_built_in_types = false +csharp_style_var_when_type_is_apparent = false + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true +csharp_style_expression_bodied_constructors = false +csharp_style_expression_bodied_indexers = true +csharp_style_expression_bodied_lambdas = true +csharp_style_expression_bodied_local_functions = false +csharp_style_expression_bodied_methods = false +csharp_style_expression_bodied_operators = false +csharp_style_expression_bodied_properties = true + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true +csharp_style_pattern_matching_over_is_with_cast_check = true +csharp_style_prefer_extended_property_pattern = true +csharp_style_prefer_not_pattern = true +csharp_style_prefer_pattern_matching = true +csharp_style_prefer_switch_expression = true + +# Null-checking preferences +csharp_style_conditional_delegate_call = true + +# Modifier preferences +csharp_prefer_static_local_function = true +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async + +# Code-block preferences +csharp_prefer_braces = true +csharp_prefer_simple_using_statement = true +csharp_style_namespace_declarations = block_scoped +csharp_style_prefer_method_group_conversion = true +csharp_style_prefer_top_level_statements = true + +# Expression-level preferences +csharp_prefer_simple_default_expression = true +csharp_style_deconstructed_variable_declaration = true +csharp_style_implicit_object_creation_when_type_is_apparent = true +csharp_style_inlined_variable_declaration = true +csharp_style_prefer_index_operator = true +csharp_style_prefer_local_over_anonymous_function = true +csharp_style_prefer_null_check_over_type_check = true +csharp_style_prefer_range_operator = true +csharp_style_prefer_tuple_swap = true +csharp_style_prefer_utf8_string_literals = true +csharp_style_throw_expression = true +csharp_style_unused_value_assignment_preference = discard_variable +csharp_style_unused_value_expression_statement_preference = discard_variable + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace + +# New line preferences +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true +csharp_style_allow_embedded_statements_on_same_line_experimental = true + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.private_or_internal_field_should_be_underscore.severity = suggestion +dotnet_naming_rule.private_or_internal_field_should_be_underscore.symbols = private_or_internal_field +dotnet_naming_rule.private_or_internal_field_should_be_underscore.style = underscore + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.private_or_internal_field.applicable_kinds = field +dotnet_naming_symbols.private_or_internal_field.applicable_accessibilities = internal, private, private_protected +dotnet_naming_symbols.private_or_internal_field.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.underscore.required_prefix = _ +dotnet_naming_style.underscore.required_suffix = +dotnet_naming_style.underscore.word_separator = +dotnet_naming_style.underscore.capitalization = camel_case diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..f83fc80 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,14 @@ + + + + enable + enable + latest + latest + + + + true + + + diff --git a/src/Inflop.VatSharp/Inflop.VatSharp.csproj b/src/Inflop.VatSharp/Inflop.VatSharp.csproj index 5587442..89be1a1 100644 --- a/src/Inflop.VatSharp/Inflop.VatSharp.csproj +++ b/src/Inflop.VatSharp/Inflop.VatSharp.csproj @@ -2,8 +2,6 @@ net8.0;net9.0;net10.0 - enable - enable diff --git a/tests/Inflop.VatSharp.Tests/DirectEngineTests.cs b/tests/Inflop.VatSharp.Tests/DirectEngineTests.cs index b18a150..3913ef1 100644 --- a/tests/Inflop.VatSharp.Tests/DirectEngineTests.cs +++ b/tests/Inflop.VatSharp.Tests/DirectEngineTests.cs @@ -111,6 +111,10 @@ public void Create_SumOfLineItemVatAmounts_PennyDifference() var m3 = engine.Calculate(ArticleItems, VatCalculationMethod.SumOfLineItemVatAmounts); (m1.TotalVat.Value - m3.TotalVat.Value).Should().Be(0.01m); + + // M.I łamie sum(LineItems.VatAmount) == TotalVat — zachowanie definiujące różnicę + // między M.I (VAT od sumy netto) a M.III (suma VAT per linia). + m1.LineItems.Sum(li => li.VatAmount.Value).Should().NotBe(m1.TotalVat.Value); } [Fact] diff --git a/tests/Inflop.VatSharp.Tests/DiscountTests.cs b/tests/Inflop.VatSharp.Tests/DiscountTests.cs index d822499..877e957 100644 --- a/tests/Inflop.VatSharp.Tests/DiscountTests.cs +++ b/tests/Inflop.VatSharp.Tests/DiscountTests.cs @@ -619,6 +619,99 @@ public void PerUnit_DiscountPerUnitRoundsUpPastUnitPrice_ClampedToZero() result.TotalVat.Value.Should().Be(0m); result.TotalGross.Value.Should().Be(0m); } + + // ── Combinatorial PerUnit gaps ─────────────────────────────────────── + + [Fact] + public void MethodI_PerUnit_MultiRate_NonDivisibleQuantity_CorrectPerRateAmounts() + { + // Linia 1 — niepodzielna: discount/qty = 5/3 = 1.6666... → round = 1.67 + // effectiveUnit = 8.33; net = 24.99; discount net = 30 − 24.99 = 5.01 + // Linia 2 — podzielna: discount/qty = 2/2 = 1.00 + // effectiveUnit = 19.00; net = 38.00; discount net = 40 − 38.00 = 2.00 + var items = new InvoiceLineItem[] + { + new(UnitPrice.Net(10m), Quantity.Of(3), VatRate.Of(23), Discount.OfAmount(5.00m)), + new(UnitPrice.Net(20m), Quantity.Of(2), VatRate.Of(8), Discount.OfAmount(2.00m)), + }; + + var result = _perUnit.Calculate(items, VatCalculationMethod.FromSumOfNetValues); + + result.VatRateSummaries.Should().HaveCount(2); + result.VatRateSummaries.Single(s => s.VatRate == VatRate.Of(23)).TotalNet.Value.Should().Be(24.99m); + result.VatRateSummaries.Single(s => s.VatRate == VatRate.Of(8)).TotalNet.Value.Should().Be(38.00m); + + result.TotalNet.Value.Should().Be(62.99m); + + // VAT M.I per rate: round(VatFromNet(sumNet)) + // 23%: round(24.99 × 0.23) = round(5.7477) = 5.75 + // 8%: round(38.00 × 0.08) = 3.04 + result.VatRateSummaries.Single(s => s.VatRate == VatRate.Of(23)).TotalVat.Value.Should().Be(5.75m); + result.VatRateSummaries.Single(s => s.VatRate == VatRate.Of(8)).TotalVat.Value.Should().Be(3.04m); + result.TotalVat.Value.Should().Be(8.79m); + + result.TotalDiscount.Value.Should().Be(7.01m); // 5.01 + 2.00 + } + + [Fact] + public void MethodIII_PerUnit_DivisibleQuantity_MatchesFromTotal() + { + // discount/qty = 4/4 = 1.00 (dokładnie podzielna) — PerUnit ≡ FromTotal dla M.III + var item = new InvoiceLineItem( + UnitPrice.Net(10m), Quantity.Of(4), VatRate.Of(23), Discount.OfAmount(4.00m)); + + var resultPerUnit = _perUnit.Calculate([item], VatCalculationMethod.SumOfLineItemVatAmounts); + var resultFromTotal = _fromTotal.Calculate([item], VatCalculationMethod.SumOfLineItemVatAmounts); + + resultPerUnit.TotalNet.Should().Be(resultFromTotal.TotalNet); + resultPerUnit.TotalVat.Should().Be(resultFromTotal.TotalVat); + resultPerUnit.TotalDiscount.Should().Be(resultFromTotal.TotalDiscount); + } + + [Fact] + public void MethodIII_PerUnit_MultiRate_PerLineVat_CorrectTotals() + { + // Linia 1 niepodzielna: net = 24.99, VAT M.III per linia = round(24.99 × 0.23) = 5.75 + // Linia 2 podzielna: net = 38.00, VAT M.III per linia = round(38.00 × 0.08) = 3.04 + var items = new InvoiceLineItem[] + { + new(UnitPrice.Net(10m), Quantity.Of(3), VatRate.Of(23), Discount.OfAmount(5.00m)), + new(UnitPrice.Net(20m), Quantity.Of(2), VatRate.Of(8), Discount.OfAmount(2.00m)), + }; + + var result = _perUnit.Calculate(items, VatCalculationMethod.SumOfLineItemVatAmounts); + + // Definiująca własność M.III: sum per-line VAT == TotalVat + var sumPerLineVat = result.LineItems.Aggregate(Money.Zero, (acc, li) => acc + li.VatAmount); + sumPerLineVat.Should().Be(result.TotalVat); + + result.VatRateSummaries.Single(s => s.VatRate == VatRate.Of(23)).TotalVat.Value.Should().Be(5.75m); + result.VatRateSummaries.Single(s => s.VatRate == VatRate.Of(8)).TotalVat.Value.Should().Be(3.04m); + result.TotalVat.Value.Should().Be(8.79m); + } + + [Fact] + public void CalculateLineItem_PerUnitEngine_AbsoluteDiscount_NonDivisibleQuantity() + { + // discount/qty = 1/3 = 0.3333... → round = 0.33 + // effectiveUnit = 12.50 − 0.33 = 12.17; totalNet = 36.51 + // VAT = round(36.51 × 0.23) = round(8.3973) = 8.40 + // Gross = 36.51 + 8.40 = 44.91 + // DiscountAmount net = 12.50×3 − 36.51 = 0.99 + var item = new InvoiceLineItem(UnitPrice.Net(12.50m), Quantity.Of(3), VatRate.Of(23), Discount.OfAmount(1.00m)); + + var result = _perUnit.CalculateLineItem(item); + + result.NetValue.Value.Should().Be(36.51m); + result.VatAmount.Value.Should().Be(8.40m); + result.GrossValue.Value.Should().Be(44.91m); + result.DiscountAmount.Value.Should().Be(0.99m); + + // Kontrast: FromTotal dałoby NetValue = 36.50, DiscountAmount = 1.00 + var fromTotalResult = _fromTotal.CalculateLineItem(item); + fromTotalResult.NetValue.Value.Should().Be(36.50m); + fromTotalResult.DiscountAmount.Value.Should().Be(1.00m); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -631,8 +724,7 @@ public class FullDiscountTests public void MethodI_100PercentDiscount_AllAmountsZero() { var engine = VatCalculationEngine.Create(); - var item = new InvoiceLineItem( - UnitPrice.Net(100m), Quantity.One, VatRate.Of(23), Discount.OfPercentage(100m)); + var item = new InvoiceLineItem(UnitPrice.Net(100m), Quantity.One, VatRate.Of(23), Discount.OfPercentage(100m)); var result = engine.Calculate([item], VatCalculationMethod.FromSumOfNetValues); @@ -646,8 +738,7 @@ public void MethodI_100PercentDiscount_AllAmountsZero() public void MethodII_100PercentDiscount_AllAmountsZero() { var engine = VatCalculationEngine.Create(); - var item = new InvoiceLineItem( - UnitPrice.Gross(123m), Quantity.One, VatRate.Of(23), Discount.OfPercentage(100m)); + var item = new InvoiceLineItem(UnitPrice.Gross(123m), Quantity.One, VatRate.Of(23), Discount.OfPercentage(100m)); var result = engine.Calculate([item], VatCalculationMethod.FromSumOfGrossValues); @@ -660,8 +751,7 @@ public void MethodII_100PercentDiscount_AllAmountsZero() public void MethodIII_100PercentDiscount_AllAmountsZero() { var engine = VatCalculationEngine.Create(); - var item = new InvoiceLineItem( - UnitPrice.Net(100m), Quantity.One, VatRate.Of(23), Discount.OfPercentage(100m)); + var item = new InvoiceLineItem(UnitPrice.Net(100m), Quantity.One, VatRate.Of(23), Discount.OfPercentage(100m)); var result = engine.Calculate([item], VatCalculationMethod.SumOfLineItemVatAmounts); @@ -685,11 +775,7 @@ public void MethodII_GrossPrice_AbsoluteDiscount_ReducesTaxableBase() // VatFromGross: 113.00 × 23/123 = 21.13008 → 21.13 (rounded) // Net: 113.00 − 21.13 = 91.87 var engine = VatCalculationEngine.Create(); - var item = new InvoiceLineItem( - UnitPrice.Gross(123m), - Quantity.One, - VatRate.Of(23), - Discount.OfAmount(10m)); + var item = new InvoiceLineItem(UnitPrice.Gross(123m), Quantity.One, VatRate.Of(23), Discount.OfAmount(10m)); var result = engine.Calculate([item], VatCalculationMethod.FromSumOfGrossValues); diff --git a/tests/Inflop.VatSharp.Tests/ForeignCurrencyCalculatorTests.cs b/tests/Inflop.VatSharp.Tests/ForeignCurrencyCalculatorTests.cs index 14df85a..b221d57 100644 --- a/tests/Inflop.VatSharp.Tests/ForeignCurrencyCalculatorTests.cs +++ b/tests/Inflop.VatSharp.Tests/ForeignCurrencyCalculatorTests.cs @@ -370,4 +370,64 @@ public void MethodIII_Fcy_ProducesDifferentVatBaseThanMethodI_ForMultipleItemsSa resultIII.TotalVatBase.Value.Should().Be(30.23m); // round(7.14 × 4.2345) resultI.TotalVatBase.Value.Should().Be(30.18m); // round(131.23 × 0.23) — derived from NetBase } + + // ── FCY + PerUnit discount behavior ────────────────────────────────── + + [Fact] + public void MethodI_Fcy_PerUnit_NonDivisibleQuantity_CorrectBaseAmounts() + { + // Tests that discountBehavior is properly propagated from + // VatCalculationEngine.Create(...) through to the FCY pipeline. + var engine = VatCalculationEngine.Create(discountBehavior: Strategies.Discount.PerUnitAbsoluteDiscountBehavior.Instance); + + var item = new InvoiceLineItem(UnitPrice.Net(10m), Quantity.Of(3), VatRate.Of(23), Discount.OfAmount(5.00m)); + // PerUnit: discount/qty = 5/3 = 1.6666... → round = 1.67 + // effectiveUnit = 8.33; totalNet = 24.99 + // VAT (M.I) = round(24.99 × 0.23) = round(5.7477) = 5.75 + // Gross = 30.74 + + var result = engine.Calculate([item], VatCalculationMethod.FromSumOfNetValues, EurPln); + + // FCY (EUR) + result.TotalNet.Value.Should().Be(24.99m); + result.TotalVat.Value.Should().Be(5.75m); + result.TotalGross.Value.Should().Be(30.74m); + + // Base (PLN) — strategy.BuildSummaryFcy dla M.I: + // NetBase = round(24.99 × 4.2345) = round(105.820155) = 105.82 + // VatBase = round(105.82 × 0.23) = round(24.3386) = 24.34 + // GrossBase = 105.82 + 24.34 = 130.16 + // DiscountBase = round(5.01 × 4.2345) = round(21.214845) = 21.21 + result.TotalNetBase.Value.Should().Be(105.82m); + result.TotalVatBase.Value.Should().Be(24.34m); + result.TotalGrossBase.Value.Should().Be(130.16m); + result.TotalDiscountBase.Value.Should().Be(21.21m); + } + + // ── ToBaseDocumentAmounts — LineItems pozostają w walucie obcej ────── + + [Fact] + public void ToBaseDocumentAmounts_LineItems_RemainInForeignCurrency() + { + // Per art. 106e ust. 11 ustawy o VAT i art. 91 dyrektywy 2006/112/EC: + // tylko VAT totals i per-rate summaries muszą być w walucie rozliczeniowej. + // LineItems celowo pozostają w walucie faktury — bez tej własności + // ktoś nieświadomy mógłby "naprawić" pozorną niespójność. + var engine = VatCalculationEngine.Create(); + var item = new InvoiceLineItem(UnitPrice.Net(100m), Quantity.One, VatRate.Of(23)); + + var fcy = engine.Calculate([item], VatCalculationMethod.FromSumOfNetValues, EurPln); + var asBase = fcy.ToBaseDocumentAmounts(); + + // Totale i VAT summaries są w PLN (base currency) + asBase.TotalNet.Value.Should().Be(fcy.TotalNetBase.Value); + asBase.TotalVat.Value.Should().Be(fcy.TotalVatBase.Value); + asBase.TotalGross.Value.Should().Be(fcy.TotalGrossBase.Value); + asBase.VatRateSummaries.Single().TotalNet.Value + .Should().Be(fcy.VatRateSummaries.Single().TotalNetBase.Value); + + // ALE LineItems pozostają w EUR — art. 106e ust. 11 ustawy o VAT + asBase.LineItems.Should().BeEquivalentTo(fcy.LineItems); + asBase.LineItems.Single().NetValue.Value.Should().Be(100m); // EUR, nie PLN + } } diff --git a/tests/Inflop.VatSharp.Tests/Inflop.VatSharp.Tests.csproj b/tests/Inflop.VatSharp.Tests/Inflop.VatSharp.Tests.csproj index 5b198c7..5fff8cf 100644 --- a/tests/Inflop.VatSharp.Tests/Inflop.VatSharp.Tests.csproj +++ b/tests/Inflop.VatSharp.Tests/Inflop.VatSharp.Tests.csproj @@ -2,9 +2,8 @@ net10.0 - enable - enable false + false diff --git a/tests/Inflop.VatSharp.Tests/InvariantTests.cs b/tests/Inflop.VatSharp.Tests/InvariantTests.cs new file mode 100644 index 0000000..0e738ae --- /dev/null +++ b/tests/Inflop.VatSharp.Tests/InvariantTests.cs @@ -0,0 +1,199 @@ +using FluentAssertions; +using Inflop.VatSharp.Enums; +using Inflop.VatSharp.Exceptions; +using Inflop.VatSharp.Mapping; +using Inflop.VatSharp.ValueObjects; +using Xunit; + +namespace Inflop.VatSharp.Tests; + +// ═══════════════════════════════════════════════════════════════════════════ +// Structural invariants and per-method properties +// ═══════════════════════════════════════════════════════════════════════════ + +public class InvariantTests +{ + private static readonly InvoiceLineItem[] NetItems = + [ + new(UnitPrice.Net(9.99m), Quantity.Of(7), VatRate.Of(23), Discount.OfPercentage(10m)), + new(UnitPrice.Net(14.50m), Quantity.Of(3), VatRate.Of(23)), + new(UnitPrice.Net(5.25m), Quantity.Of(5), VatRate.Of(8), Discount.OfAmount(2.00m)), + ]; + + private static readonly InvoiceLineItem[] GrossItems = + [ + new(UnitPrice.Gross(12.29m), Quantity.Of(7), VatRate.Of(23), Discount.OfPercentage(10m)), + new(UnitPrice.Gross(17.84m), Quantity.Of(3), VatRate.Of(23)), + new(UnitPrice.Gross(5.67m), Quantity.Of(5), VatRate.Of(8), Discount.OfAmount(2.00m)), + ]; + + private static readonly InvoiceLineItem[] ArticleItems = + [ + new(UnitPrice.Net(4.58m), Quantity.Of(4), VatRate.Of(23)), + new(UnitPrice.Net(7.22m), Quantity.Of(5), VatRate.Of(23)), + new(UnitPrice.Net(12.74m), Quantity.Of(2), VatRate.Of(8)), + ]; + + private readonly LineItemCalculationEngine _engine = VatCalculationEngine.Create(); + + public static IEnumerable MethodWithCompatibleDataset => + [ + [VatCalculationMethod.FromSumOfNetValues, NetItems], + [VatCalculationMethod.FromSumOfGrossValues, GrossItems], + [VatCalculationMethod.SumOfLineItemVatAmounts, NetItems], + ]; + + public static IEnumerable EachMethod => + [ + [VatCalculationMethod.FromSumOfNetValues], + [VatCalculationMethod.FromSumOfGrossValues], + [VatCalculationMethod.SumOfLineItemVatAmounts], + ]; + + // ── Niezmienniki zawsze prawdziwe ──────────────────────────────────── + + [Theory, MemberData(nameof(MethodWithCompatibleDataset))] + public void DocumentAmounts_TotalGross_EqualsTotalNetPlusTotalVat(VatCalculationMethod method, InvoiceLineItem[] items) + { + var result = _engine.Calculate(items, method); + result.TotalGross.Should().Be(result.TotalNet + result.TotalVat); + } + + [Theory, MemberData(nameof(MethodWithCompatibleDataset))] + public void VatRateSummary_EachSummary_TotalGrossEqualsTotalNetPlusTotalVat(VatCalculationMethod method, InvoiceLineItem[] items) + { + var result = _engine.Calculate(items, method); + result.VatRateSummaries.Should().AllSatisfy(s => s.TotalGross.Should().Be(s.TotalNet + s.TotalVat)); + } + + [Theory, MemberData(nameof(MethodWithCompatibleDataset))] + public void DocumentAmounts_TotalNet_EqualsSumOfRateSummaryNets(VatCalculationMethod method, InvoiceLineItem[] items) + { + var result = _engine.Calculate(items, method); + var sum = result.VatRateSummaries.Aggregate(Money.Zero, (acc, s) => acc + s.TotalNet); + sum.Should().Be(result.TotalNet); + } + + [Theory, MemberData(nameof(MethodWithCompatibleDataset))] + public void DocumentAmounts_TotalVat_EqualsSumOfRateSummaryVats(VatCalculationMethod method, InvoiceLineItem[] items) + { + var result = _engine.Calculate(items, method); + var sum = result.VatRateSummaries.Aggregate(Money.Zero, (acc, s) => acc + s.TotalVat); + sum.Should().Be(result.TotalVat); + } + + [Theory, MemberData(nameof(MethodWithCompatibleDataset))] + public void DocumentAmounts_TotalDiscount_EqualsSumOfRateSummaryDiscounts(VatCalculationMethod method, InvoiceLineItem[] items) + { + var result = _engine.Calculate(items, method); + var sum = result.VatRateSummaries.Aggregate(Money.Zero, (acc, s) => acc + s.TotalDiscount); + sum.Should().Be(result.TotalDiscount); + } + + [Theory, MemberData(nameof(MethodWithCompatibleDataset))] + public void DocumentAmounts_Method_MatchesRequestedMethod(VatCalculationMethod method, InvoiceLineItem[] items) + { + var result = _engine.Calculate(items, method); + result.Method.Should().Be(method); + } + + [Theory, MemberData(nameof(MethodWithCompatibleDataset))] + public void DocumentAmounts_LineItemCount_MatchesInputCount(VatCalculationMethod method, InvoiceLineItem[] items) + { + var result = _engine.Calculate(items, method); + result.LineItems.Should().HaveCount(items.Length); + } + + // ── Adytywność per metoda ──────────────────────────────────────────── + + [Fact] + public void MethodI_SumOfLineItemNets_EqualsTotalNet() + { + var result = _engine.Calculate(NetItems, VatCalculationMethod.FromSumOfNetValues); + var sum = result.LineItems.Aggregate(Money.Zero, (acc, li) => acc + li.NetValue); + sum.Should().Be(result.TotalNet); + } + + [Fact] + public void MethodII_SumOfLineItemGross_EqualsTotalGross() + { + var result = _engine.Calculate(GrossItems, VatCalculationMethod.FromSumOfGrossValues); + var sum = result.LineItems.Aggregate(Money.Zero, (acc, li) => acc + li.GrossValue); + sum.Should().Be(result.TotalGross); + } + + [Fact] + public void MethodIII_SumOfLineItemVatAmounts_EqualsTotalVat() + { + var result = _engine.Calculate(NetItems, VatCalculationMethod.SumOfLineItemVatAmounts); + var sum = result.LineItems.Aggregate(Money.Zero, (acc, li) => acc + li.VatAmount); + sum.Should().Be(result.TotalVat); + } + + [Fact] + public void MethodIII_SumOfLineItemNets_EqualsTotalNet() + { + var result = _engine.Calculate(NetItems, VatCalculationMethod.SumOfLineItemVatAmounts); + var sum = result.LineItems.Aggregate(Money.Zero, (acc, li) => acc + li.NetValue); + sum.Should().Be(result.TotalNet); + } + + // ── Idempotency ────────────────────────────────────────────────────── + + [Theory, MemberData(nameof(MethodWithCompatibleDataset))] + public void Idempotency_DomesticCalculation_ProducesBitEqualResults(VatCalculationMethod method, InvoiceLineItem[] items) + { + var first = _engine.Calculate(items, method); + var second = _engine.Calculate(items, method); + + first.Should().BeEquivalentTo(second); + } + + [Fact] + public void Idempotency_FcyCalculation_ProducesBitEqualResults() + { + var rate = ExchangeRate.Of(CurrencyCode.EUR, CurrencyCode.PLN, 4.2345m, new DateOnly(2024, 10, 21), "NBP"); + var first = _engine.Calculate(NetItems, VatCalculationMethod.FromSumOfNetValues, rate); + var second = _engine.Calculate(NetItems, VatCalculationMethod.FromSumOfNetValues, rate); + + first.Should().BeEquivalentTo(second); + } + + // ── Empty / single-item edge cases ─────────────────────────────────── + + [Theory, MemberData(nameof(EachMethod))] + public void SingleItem_AnyMethod_ProducesValidResult(VatCalculationMethod method) + { + var item = method == VatCalculationMethod.FromSumOfGrossValues + ? new InvoiceLineItem(UnitPrice.Gross(123m), Quantity.One, VatRate.Of(23)) + : new InvoiceLineItem(UnitPrice.Net(100m), Quantity.One, VatRate.Of(23)); + + var result = _engine.Calculate([item], method); + + result.VatRateSummaries.Should().HaveCount(1); + result.LineItems.Should().HaveCount(1); + result.TotalGross.Should().Be(result.TotalNet + result.TotalVat); + result.Method.Should().Be(method); + } + + [Theory, MemberData(nameof(EachMethod))] + public void EmptyItems_AnyMethod_Throws(VatCalculationMethod method) + { + var act = () => _engine.Calculate([], method); + act.Should().Throw(); + } + + [Theory, MemberData(nameof(EachMethod))] + public void SingleItem_ZeroRateOnly_NoVat(VatCalculationMethod method) + { + var item = method == VatCalculationMethod.FromSumOfGrossValues + ? new InvoiceLineItem(UnitPrice.Gross(1000m), Quantity.One, VatRate.Zero) + : new InvoiceLineItem(UnitPrice.Net(1000m), Quantity.One, VatRate.Zero); + + var result = _engine.Calculate([item], method); + + result.TotalVat.Value.Should().Be(0m); + result.TotalNet.Value.Should().Be(1000m); + result.TotalGross.Value.Should().Be(1000m); + } +} diff --git a/tests/Inflop.VatSharp.Tests/RoundingInvariantTests.cs b/tests/Inflop.VatSharp.Tests/RoundingInvariantTests.cs new file mode 100644 index 0000000..bdde90f --- /dev/null +++ b/tests/Inflop.VatSharp.Tests/RoundingInvariantTests.cs @@ -0,0 +1,58 @@ +using FluentAssertions; +using Inflop.VatSharp.Enums; +using Inflop.VatSharp.Mapping; +using Inflop.VatSharp.ValueObjects; +using Xunit; + +namespace Inflop.VatSharp.Tests; + +// ═══════════════════════════════════════════════════════════════════════════ +// Rounding behavior in calculation pipeline. +// Defends against silent regression to Banker's rounding (ToEven). +// Polish VAT law (ustawa o VAT) and EU Directive 2006/112/EC require +// AwayFromZero rounding for tax amounts at midpoints. +// ═══════════════════════════════════════════════════════════════════════════ + +public class RoundingInvariantTests +{ + private readonly LineItemCalculationEngine _engine = VatCalculationEngine.Create(); + + [Fact] + public void MethodI_MidpointVat_RoundsAwayFromZero_NotBankersRounding() + { + // Net(1.00) × 0.5% = 0.005m exact midpoint + // AwayFromZero → 0.01; Banker's → 0.00 + var item = new InvoiceLineItem(UnitPrice.Net(1m), Quantity.Of(1), VatRate.Of(0.5m)); + + var result = _engine.Calculate([item], VatCalculationMethod.FromSumOfNetValues); + + result.TotalVat.Value.Should().Be(0.01m); + } + + [Fact] + public void MethodII_MidpointVat_RoundsAwayFromZero_NotBankersRounding() + { + // Gross(1.005) × 0.5/100.5 = 0.005m exact midpoint + // With AwayFromZero: itemGross.Round(1.005) = 1.01; + // VatFromGross(1.01) = 0.005024..., round = 0.01 + // With Banker's: itemGross.Round(1.005) = 1.00; + // VatFromGross(1.00) = 0.004975..., round = 0.00 + // Money.Of(1.005m) is allowed — Money does not enforce 2dp on input. + var item = new InvoiceLineItem(UnitPrice.Gross(1.005m), Quantity.Of(1), VatRate.Of(0.5m)); + + var result = _engine.Calculate([item], VatCalculationMethod.FromSumOfGrossValues); + + result.TotalVat.Value.Should().Be(0.01m); + } + + [Fact] + public void MethodIII_MidpointVat_PerLine_RoundsAwayFromZero() + { + var item = new InvoiceLineItem(UnitPrice.Net(1m), Quantity.Of(1), VatRate.Of(0.5m)); + + var result = _engine.Calculate([item], VatCalculationMethod.SumOfLineItemVatAmounts); + + result.TotalVat.Value.Should().Be(0.01m); + result.LineItems[0].VatAmount.Value.Should().Be(0.01m); + } +}