From 6aefa475df32f91c098416e38b00dfcb5c0d2c0b Mon Sep 17 00:00:00 2001 From: "Claude agent (rpi5)" Date: Sat, 7 Nov 2026 14:44:22 +0100 Subject: [PATCH] agent: ksef-cli: kontrola arytmetyki VAT w validate (zadanie #5) --- docs/ROADMAP.md | 2 +- src/Ksef.Fa3/Fa3Validator.cs | 1 + src/Ksef.Fa3/Fa3VatArithmetic.cs | 117 ++++++++++++++++++ src/KsefCli/Commands/ValidateCommand.cs | 4 +- tests/Ksef.Fa3.Tests/Fa3VatArithmeticTests.cs | 62 ++++++++++ 5 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 src/Ksef.Fa3/Fa3VatArithmetic.cs create mode 100644 tests/Ksef.Fa3.Tests/Fa3VatArithmeticTests.cs diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 169e951..bfeb26e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,7 +21,7 @@ potoki, sekrety nigdy w argumentach. - [ ] `ksef export --state PLIK` — eksport asynchroniczny (paczki ZIP) z przyrostowym pobieraniem od HWM i jawnym plikiem stanu; wynik do potoku (`| ksef get`). MF zaleca eksport zamiast serii pojedynczych pobrań (limit 64/h). - [ ] Sesja wsadowa: `ksef send --batch`, automatycznie dla faktur z `Zalacznik` (wysyłanych tylko wsadowo). -- [ ] `validate`: arytmetyka `P_13_x` / `P_14_x` / `P_15` w groszach z tolerancją 1 gr (od sum i od wierszy), +- [ ] `validate` (częściowo: P_13_x↔P_11 i P_14_x↔P_13_x×stawka dla VAT, grupy 1–3): arytmetyka `P_13_x` / `P_14_x` / `P_15` w groszach z tolerancją 1 gr (od sum i od wierszy), z osobną semantyką dla ZAL/ROZ/KOR i stałymi kodami błędów w `--json` — bez fałszywych alarmów na poprawnych fakturach (wzór reguł: walidator ksefuj, Apache-2.0). - [ ] Błędy w `--json` na stderr z polami `ksef_code`, `trace_id` i podpowiedzią dla znanych kodów KSeF. diff --git a/src/Ksef.Fa3/Fa3Validator.cs b/src/Ksef.Fa3/Fa3Validator.cs index 0bacb69..1057047 100644 --- a/src/Ksef.Fa3/Fa3Validator.cs +++ b/src/Ksef.Fa3/Fa3Validator.cs @@ -8,6 +8,7 @@ public static IReadOnlyList Validate(byte[] xml) { List errors = [.. KsefDocumentRules.Check(xml)]; errors.AddRange(Fa3Schema.Validate(new MemoryStream(xml))); + errors.AddRange(Fa3VatArithmetic.Check(xml)); return errors; } } diff --git a/src/Ksef.Fa3/Fa3VatArithmetic.cs b/src/Ksef.Fa3/Fa3VatArithmetic.cs new file mode 100644 index 0000000..32f2b8b --- /dev/null +++ b/src/Ksef.Fa3/Fa3VatArithmetic.cs @@ -0,0 +1,117 @@ +using System.Globalization; +using System.Xml; +using System.Xml.Linq; +using System.Xml.Schema; + +namespace Ksef.Fa3; + +/// +/// Kontrola arytmetyki VAT faktury (offline): sumy podstawy P_13_x względem wierszy (P_11) i kwota podatku +/// P_14_x względem P_13_x × stawka (albo sumy P_11Vat z wierszy), z tolerancją 1 gr. +/// Zakres zachowawczy, by nie dawać fałszywych alarmów: tylko RodzajFaktury = VAT (zaliczkowe, rozliczeniowe +/// i korygujące mają inną semantykę — pomijane), tylko grupy stawek 23/22, 8/7 i 5 (P_13_1..3 / P_14_1..3); +/// grupa jest pomijana, gdy brakuje danych do porównania. P_15 nie jest sprawdzane. +/// +public static class Fa3VatArithmetic +{ + private const string Source = "KSeF"; + private const decimal Tolerance = 0.01m; + + // Numer grupy → dopuszczalne stawki P_12 (23/22 i 8/7 to stawki obowiązujące w różnych okresach). + private static readonly (int Group, string[] Rates)[] Groups = + [ + (1, ["23", "22"]), + (2, ["8", "7"]), + (3, ["5"]), + ]; + + public static IReadOnlyList Check(byte[] xml) + { + XDocument doc; + try + { + XmlReaderSettings settings = new() { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }; + using XmlReader reader = XmlReader.Create(new MemoryStream(xml), settings); + doc = XDocument.Load(reader, LoadOptions.SetLineInfo); + } + catch (XmlException) + { + return []; // błąd składni zgłasza walidacja XSD + } + + XNamespace ns = Fa3Schema.Namespace; + XElement? fa = doc.Root?.Element(ns + "Fa"); + if (fa is null || fa.Element(ns + "RodzajFaktury")?.Value.Trim() != "VAT") + { + return []; + } + + List rows = [.. fa.Elements(ns + "FaWiersz")]; + List errors = []; + foreach ((int group, string[] rates) in Groups) + { + XElement? p13 = fa.Element(ns + $"P_13_{group}"); + XElement? p14 = fa.Element(ns + $"P_14_{group}"); + if (p13 is null || Parse(p13) is not { } net) + { + continue; + } + + List groupRows = [.. rows.Where(r => rates.Contains(r.Element(ns + "P_12")?.Value.Trim()))]; + if (groupRows.Count == 0) + { + continue; + } + + if (Sum(groupRows, ns + "P_11") is { } rowsNet && Math.Abs(rowsNet - net) > Tolerance) + { + errors.Add(Error(p13, $"P_13_{group} = {Format(net)} nie zgadza się z sumą P_11 wierszy ze stawką {string.Join("/", rates)}% = {Format(rowsNet)}.")); + } + + if (p14 is null || Parse(p14) is not { } tax) + { + continue; + } + string[] distinct = [.. groupRows.Select(r => r.Element(ns + "P_12")!.Value.Trim()).Distinct(StringComparer.Ordinal)]; + if (distinct.Length != 1) + { + continue; // mieszane stawki w grupie (np. 23 i 22) — nie da się jednoznacznie wyliczyć + } + + decimal expected = Math.Round(net * decimal.Parse(distinct[0], CultureInfo.InvariantCulture) / 100m, 2, MidpointRounding.AwayFromZero); + decimal? rowsTax = Sum(groupRows, ns + "P_11Vat"); + if (Math.Abs(tax - expected) > Tolerance && (rowsTax is null || Math.Abs(tax - rowsTax.Value) > Tolerance)) + { + errors.Add(Error(p14, $"P_14_{group} = {Format(tax)}, a podstawa P_13_{group} = {Format(net)} przy stawce {distinct[0]}% daje {Format(expected)}." + + (rowsTax is { } r ? $" Suma P_11Vat wierszy = {Format(r)}." : ""))); + } + } + return errors; + } + + private static decimal? Parse(XElement e) => + decimal.TryParse(e.Value.Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out decimal v) ? v : null; + + // Suma pola we wszystkich wierszach; null, gdy któryś wiersz go nie ma (nie ma z czym porównywać). + private static decimal? Sum(List rows, XName name) + { + decimal sum = 0; + foreach (XElement row in rows) + { + if (row.Element(name) is not { } e || Parse(e) is not { } v) + { + return null; + } + sum += v; + } + return sum; + } + + private static string Format(decimal v) => v.ToString("0.00", CultureInfo.InvariantCulture); + + private static Fa3ValidationError Error(XElement at, string message) + { + IXmlLineInfo info = at; + return new(XmlSeverityType.Error, info.LineNumber, info.LinePosition, message, Source); + } +} diff --git a/src/KsefCli/Commands/ValidateCommand.cs b/src/KsefCli/Commands/ValidateCommand.cs index 98adbe8..bc7f40e 100644 --- a/src/KsefCli/Commands/ValidateCommand.cs +++ b/src/KsefCli/Commands/ValidateCommand.cs @@ -5,7 +5,7 @@ namespace KsefCli.Commands; /// -/// `ksef validate` — kontrola pliku faktury przed wysyłką: wymagania KSeF + schemat FA(3), offline. +/// `ksef validate` — kontrola pliku faktury przed wysyłką: wymagania KSeF + schemat FA(3) + arytmetyka VAT, offline. /// Przy sukcesie milczy; błędy idą na stderr w formacie `plik:wiersz:kolumna: ...` (jak kompilatory). /// public static class ValidateCommand @@ -26,7 +26,7 @@ public static Command Create() }; Option jsonOption = CliIo.JsonOption(); - Command command = new("validate", "Sprawdza plik(i) XML faktury: wymagania KSeF (kodowanie, znaki, rozmiar) i schemat FA(3), bez sieci"); + Command command = new("validate", "Sprawdza plik(i) XML faktury: wymagania KSeF (kodowanie, znaki, rozmiar), schemat FA(3) i arytmetykę VAT, bez sieci"); command.Arguments.Add(filesArgument); command.Options.Add(verboseOption); command.Options.Add(jsonOption); diff --git a/tests/Ksef.Fa3.Tests/Fa3VatArithmeticTests.cs b/tests/Ksef.Fa3.Tests/Fa3VatArithmeticTests.cs new file mode 100644 index 0000000..63df46b --- /dev/null +++ b/tests/Ksef.Fa3.Tests/Fa3VatArithmeticTests.cs @@ -0,0 +1,62 @@ +using System.Text; + +namespace Ksef.Fa3.Tests; + +public class Fa3VatArithmeticTests +{ + private static string ValidXml => TestInvoices.Fill("invoice-template-fa-3.xml"); + + [Fact] + public void CorrectInvoice_HasNoVatErrors() + { + Assert.Empty(Fa3VatArithmetic.Check(Utf8(ValidXml))); + Assert.Empty(Fa3Validator.Validate(Utf8(ValidXml))); + } + + [Fact] + public void WrongP14_IsReportedWithPosition() + { + string xml = ValidXml.Replace("10.03", "11.03", StringComparison.Ordinal); + + Fa3ValidationError error = Assert.Single(Fa3Validator.Validate(Utf8(xml))); + + Assert.Contains("P_14_1 = 11.03", error.Message); + Assert.True(error.Line > 1); + } + + [Fact] + public void P14_OffByOneGrosz_IsTolerated() + { + string xml = ValidXml.Replace("10.03", "10.04", StringComparison.Ordinal); + + Assert.Empty(Fa3VatArithmetic.Check(Utf8(xml))); + } + + [Fact] + public void WrongP13_IsReported() + { + string xml = ValidXml.Replace("43.60", "50.00", StringComparison.Ordinal); + + Assert.Contains(Fa3VatArithmetic.Check(Utf8(xml)), e => e.Message.Contains("P_13_1 = 50.00")); + } + + [Fact] + public void NonVatInvoiceKinds_AreSkipped() + { + string xml = ValidXml.Replace("10.03", "99.00", StringComparison.Ordinal) + .Replace("VAT", "ZAL", StringComparison.Ordinal); + + Assert.Empty(Fa3VatArithmetic.Check(Utf8(xml))); + } + + [Fact] + public void MfSamples_HaveNoFalseAlarms() + { + foreach (string template in new[] { "invoice-template-fa-3-with-custom-Subject3.xml", "invoice-template-fa-3-with-attachment.xml", "invoice-template-fa-3-with-custom-Subject2.xml" }) + { + Assert.Empty(Fa3VatArithmetic.Check(Utf8(TestInvoices.Fill(template)))); + } + } + + private static byte[] Utf8(string s) => new UTF8Encoding(false).GetBytes(s); +}