Emit invoices as validated ZUGFeRD (DATEV) and XRechnung (B2G)
Playwright Tests / test (pull_request) Has been cancelled

Completes the eRechnung output path: finalized invoices are emitted as a
ZUGFeRD/Factur-X EN 16931 hybrid (DATEV) or, when a Leitweg-ID is present, as
XRechnung 3.0 for B2G. Both are externally validated as ACCEPTED (0 errors) and
PDF/A-3B COMPLIANT against the ProcessWeb eInvoice service.

- ERechnungMapper: FdsInvoiceData -> EN 16931 model. Seller master data now from
  Fuchs:ERechnung:Seller config (VAT id BT-31, Steuernummer BT-32, optional
  Handelsregister BT-30, IBAN/BIC, contact). Adds payment terms BT-20/BT-9
  (BR-CO-25), buyer VAT id, §13b reverse charge, and the service date/period
  (BT-72 / BG-14) parsed from ProvisionPeriod (BR-DE-TMP-32). B2G -> XRechnung
  profile with the required electronic addresses/contact.
- ERechnungValidator: client for POST /validatepdf (EN 16931 XML + PDF/A-3 in
  one call). A pure "scenario not matched" with zero errors is not treated as a
  hard failure; real errors optionally withhold the hybrid (FailOnError).
- ERechnungSettings: seller + validation config; wired in Program.cs; ServiceUrl
  in appsettings.
- Online editor: structured German-only dialogs for the recipient address
  (incl. Leitweg-ID) and the service date/period (single date or range).
- Bumps the eRechnungLib submodule to the CII rsm-namespace / PDF-A subtype fix.

Fuchs.Tests 514/514, eRechnungLib 97/97.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 00:05:24 +02:00
co-authored by Claude Opus 4.8
parent d94974ce06
commit 00e72c96d4
19 changed files with 847 additions and 80 deletions
+70 -1
View File
@@ -18,7 +18,7 @@ namespace Fuchs.Tests;
public class ERechnungMapperTests
{
private static FdsInvoiceData BuildInvoice(string sendToAddressJson, string vat = "19",
string invoiceOptions = "", bool withItem = true)
string invoiceOptions = "", bool withItem = true, string provisionPeriod = "")
{
var items = withItem
? "[{'id':'900','type':'material','title':'Reparatur','desc':'Vor Ort','qty':2,'price_net':50,'total_net':100,'vat':'" + vat + "'}]"
@@ -35,6 +35,7 @@ public class ERechnungMapperTests
["InvoiceOptions"] = invoiceOptions,
["InvoiceBalance_net"] = "100",
["InvoiceVAT_1"] = vat,
["ProvisionPeriod"] = provisionPeriod,
["SendToAddressJson"] = sendToAddressJson,
})
};
@@ -120,4 +121,72 @@ public class ERechnungMapperTests
[InlineData("", "DE")]
public void NormalizeCountry_MapsNamesAndCodes(string raw, string expected)
=> Assert.Equal(expected, ERechnungMapper.NormalizeCountry(raw).Value);
[Fact]
public void ServicePeriod_SingleDate_MapsToDeliveryDate()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, provisionPeriod: "18.06.2026")).Model;
Assert.NotNull(model.Delivery);
Assert.Equal(new System.DateOnly(2026, 6, 18), model.Delivery!.DeliveryDate);
Assert.Null(model.InvoicingPeriod);
}
[Fact]
public void ServicePeriod_DateRange_MapsToInvoicingPeriod()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, provisionPeriod: "01.06.2026 - 30.06.2026")).Model;
Assert.NotNull(model.InvoicingPeriod);
Assert.Equal(new System.DateOnly(2026, 6, 1), model.InvoicingPeriod!.StartDate);
Assert.Equal(new System.DateOnly(2026, 6, 30), model.InvoicingPeriod.EndDate);
}
[Fact]
public void Seller_ComesFromSettings_WhenProvided()
{
var seller = new ERechnungSellerSettings { Name = "Test Handwerk GmbH", VatId = "DE999999999", Iban = "DE00" };
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress), seller).Model;
Assert.Equal("Test Handwerk GmbH", model.Seller.Name);
Assert.Equal("DE999999999", model.Seller.VatId);
}
[Fact]
public void B2G_WithLeitwegId_SetsBuyerReference_AndSellerContact()
{
var b2g = "{'name':'Stadt Düsseldorf','street':'Marktplatz 2','postalCode':'40213','city':'Düsseldorf','countryCode':'DE','leitwegId':'05111-12345-67'}";
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(b2g)).Model;
Assert.Equal("05111-12345-67", model.BuyerReference); // BT-10 (Leitweg-ID)
Assert.NotNull(model.Seller.ElectronicAddress); // BT-34 (XRechnung)
Assert.NotNull(model.Seller.Contact); // BG-6 (BR-DE-5/6/7)
Assert.Equal("info@sanitaerfuchs.de", model.Seller.Contact!.Email);
}
[Fact]
public void B2B_NoLeitwegId_LeavesBuyerReferenceUnset()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress)).Model;
Assert.True(string.IsNullOrEmpty(model.BuyerReference));
}
[Fact]
public void Seller_HasVatIdAndPaymentTerms_ForBrCo25AndBrCo26()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress)).Model;
// BR-CO-26: seller VAT id (BT-31) present, in addition to the Steuernummer (BT-32).
Assert.Equal("DE286366012", model.Seller.VatId);
// BR-CO-25: payment terms (BT-20) / due date (BT-9) present for a positive amount due.
Assert.NotNull(model.PaymentTerms);
Assert.False(string.IsNullOrWhiteSpace(model.PaymentTerms!.Description));
}
[Fact]
public void B2G_ToZugferdXRechnung_ProducesHybridWithXRechnungCustomization()
{
var b2g = "{'name':'Stadt Düsseldorf','street':'Marktplatz 2','postalCode':'40213','city':'Düsseldorf','countryCode':'DE','leitwegId':'05111-12345-67'}";
var result = ERechnungMapper.BuildEInvoice(BuildInvoice(b2g)).ToZugferd(ZugferdProfile.XRechnung);
Assert.True(result.Success);
string content = System.Text.Encoding.Latin1.GetString(result.Value!);
Assert.Contains("xrechnung_3.0", content); // XRechnung 3.0 CIUS customization id
}
}
+114
View File
@@ -0,0 +1,114 @@
using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Exercises the external eRechnung validator client against a stubbed HTTP endpoint: response
/// parsing, the not-configured short-circuit, and unreachable/error handling (never throws).
/// </summary>
public class ERechnungValidatorTests
{
private sealed class StubHandler : HttpMessageHandler
{
private readonly HttpStatusCode _status;
private readonly string _body;
public HttpRequestMessage? Last;
public StubHandler(HttpStatusCode status, string body) { _status = status; _body = body; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
Last = request;
return Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
}
}
private sealed class StubFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public StubFactory(HttpMessageHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
}
private static ProcessWebERechnungValidator Make(HttpMessageHandler handler, bool enabled = true,
string url = "https://validator.test/api/eInvoice")
{
var settings = Options.Create(new ERechnungSettings
{
Validation = new ERechnungValidationSettings { Enabled = enabled, ServiceUrl = url }
});
return new ProcessWebERechnungValidator(new StubFactory(handler), settings,
NullLogger<ProcessWebERechnungValidator>.Instance);
}
[Fact]
public async Task ValidatePdf_BothPass_ReturnsIsValid()
{
var handler = new StubHandler(HttpStatusCode.OK,
"{\"isValid\":true,\"summary\":\"ok\",\"xml\":{\"isValid\":true},\"pdfa\":{\"isCompliant\":true}}");
var result = await Make(handler).ValidatePdfAsync(new byte[] { 1, 2, 3 });
Assert.True(result.IsValid);
Assert.True(result.XmlValid);
Assert.True(result.PdfACompliant);
Assert.EndsWith("/validatepdf", handler.Last!.RequestUri!.ToString());
Assert.Equal("application/pdf", handler.Last.Content!.Headers.ContentType!.MediaType);
}
[Theory]
[InlineData("{\"xml\":{\"isValid\":false,\"errorCount\":3},\"pdfa\":{\"isCompliant\":true}}", false, true)]
[InlineData("{\"xml\":{\"isValid\":true},\"pdfa\":{\"isCompliant\":false}}", true, false)]
public async Task ValidatePdf_PartialFailure_IsNotValid(string body, bool xml, bool pdfa)
{
var result = await Make(new StubHandler(HttpStatusCode.OK, body)).ValidatePdfAsync(new byte[] { 1 });
Assert.False(result.IsValid);
Assert.Equal(xml, result.XmlValid);
Assert.Equal(pdfa, result.PdfACompliant);
Assert.True(result.HasHardError); // real PDF/A or XML errors are hard failures
}
[Fact]
public async Task ValidatePdf_PdfAOk_ButXmlScenarioNotMatched_IsNotHardError()
{
// EN 16931 ZUGFeRD checked by an XRechnung-only scenario set: PDF/A compliant, XML rejected
// with zero errors → not strictly valid, but not a hard error (must not withhold the invoice).
var body = "{\"xml\":{\"isValid\":false,\"scenarioMatched\":false,\"errorCount\":0},\"pdfa\":{\"isCompliant\":true}}";
var result = await Make(new StubHandler(HttpStatusCode.OK, body)).ValidatePdfAsync(new byte[] { 1 });
Assert.True(result.PdfACompliant);
Assert.False(result.ScenarioMatched);
Assert.False(result.IsValid);
Assert.False(result.HasHardError);
}
[Fact]
public async Task ValidatePdf_Disabled_ReturnsNotConfigured()
{
var result = await Make(new StubHandler(HttpStatusCode.OK, "{}"), enabled: false).ValidatePdfAsync(new byte[] { 1 });
Assert.False(result.Configured);
Assert.False(result.IsValid);
}
[Fact]
public async Task ValidatePdf_ServerError_IsReachedFalse_AndDoesNotThrow()
{
var result = await Make(new StubHandler(HttpStatusCode.BadGateway, "validator down")).ValidatePdfAsync(new byte[] { 1 });
Assert.False(result.Reached);
Assert.False(result.IsValid);
}
[Fact]
public async Task ValidatePdf_Enabled_RequiresNonEmptyUrl()
{
var v = Make(new StubHandler(HttpStatusCode.OK, "{}"), url: "");
Assert.False(v.Enabled);
var result = await v.ValidatePdfAsync(new byte[] { 1 });
Assert.False(result.Configured);
}
}