Emit invoices as validated ZUGFeRD (DATEV) and XRechnung (B2G)
Playwright Tests / test (pull_request) Has been cancelled
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user