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 docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/Ksef.Fa3/Fa3Validator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public static IReadOnlyList<Fa3ValidationError> Validate(byte[] xml)
{
List<Fa3ValidationError> errors = [.. KsefDocumentRules.Check(xml)];
errors.AddRange(Fa3Schema.Validate(new MemoryStream(xml)));
errors.AddRange(Fa3VatArithmetic.Check(xml));
return errors;
}
}
117 changes: 117 additions & 0 deletions src/Ksef.Fa3/Fa3VatArithmetic.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using System.Globalization;
using System.Xml;
using System.Xml.Linq;
using System.Xml.Schema;

namespace Ksef.Fa3;

/// <summary>
/// Kontrola arytmetyki VAT faktury (offline): sumy podstawy <c>P_13_x</c> względem wierszy (<c>P_11</c>) i kwota podatku
/// <c>P_14_x</c> względem <c>P_13_x</c> × stawka (albo sumy <c>P_11Vat</c> z wierszy), z tolerancją 1 gr.
/// Zakres zachowawczy, by nie dawać fałszywych alarmów: tylko <c>RodzajFaktury</c> = 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. <c>P_15</c> nie jest sprawdzane.
/// </summary>
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<Fa3ValidationError> 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<XElement> rows = [.. fa.Elements(ns + "FaWiersz")];
List<Fa3ValidationError> 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<XElement> 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<XElement> 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);
}
}
4 changes: 2 additions & 2 deletions src/KsefCli/Commands/ValidateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
namespace KsefCli.Commands;

/// <summary>
/// `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).
/// </summary>
public static class ValidateCommand
Expand All @@ -26,7 +26,7 @@ public static Command Create()
};
Option<bool> 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);
Expand Down
62 changes: 62 additions & 0 deletions tests/Ksef.Fa3.Tests/Fa3VatArithmeticTests.cs
Original file line number Diff line number Diff line change
@@ -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("<P_14_1>10.03</P_14_1>", "<P_14_1>11.03</P_14_1>", 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("<P_14_1>10.03</P_14_1>", "<P_14_1>10.04</P_14_1>", StringComparison.Ordinal);

Assert.Empty(Fa3VatArithmetic.Check(Utf8(xml)));
}

[Fact]
public void WrongP13_IsReported()
{
string xml = ValidXml.Replace("<P_13_1>43.60</P_13_1>", "<P_13_1>50.00</P_13_1>", 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("<P_14_1>10.03</P_14_1>", "<P_14_1>99.00</P_14_1>", StringComparison.Ordinal)
.Replace("<RodzajFaktury>VAT</RodzajFaktury>", "<RodzajFaktury>ZAL</RodzajFaktury>", 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);
}
Loading