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:
@@ -12,27 +12,21 @@ namespace Fuchs.Services;
|
||||
/// <c>eRechnungLib</c>, so it can be emitted as a ZUGFeRD/Factur-X hybrid (see ADR 0005/0012).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The seller (<c>BG-4</c>) is Fuchs itself; its master data currently lives as constants in
|
||||
/// <c>FuchsPdf</c> (letterhead, Steuernummer, bank) and is mirrored here — a future change should
|
||||
/// lift both into shared configuration. The buyer (<c>BG-7</c>) comes from the structured
|
||||
/// The seller (<c>BG-4</c>) is Fuchs itself; its master data comes from
|
||||
/// <see cref="ERechnungSellerSettings"/> (<c>Fuchs:ERechnung:Seller</c> in appsettings, defaults
|
||||
/// mirroring the FuchsPdf letterhead). The buyer (<c>BG-7</c>) comes from the structured
|
||||
/// <see cref="FdsInvoiceData.RecipientAddress"/>; a private person (no VAT id) maps cleanly with
|
||||
/// no buyer tax registration. Amounts/VAT are recomputed by the library from the mapped lines.
|
||||
/// </remarks>
|
||||
public static class ERechnungMapper
|
||||
{
|
||||
// Seller master data — mirrors the FuchsPdf letterhead/footer constants (ADR 0005).
|
||||
private const string SellerName = "Sebastian Fuchs GmbH & Co. KG";
|
||||
private const string SellerStreet = "Germaniastraße 15";
|
||||
private const string SellerPostalCode = "40223";
|
||||
private const string SellerCity = "Düsseldorf";
|
||||
private const string SellerTaxNumber = "106/5849/2962"; // Steuernummer (BT-32)
|
||||
private const string SellerIban = "DE76300501100045014800"; // Stadtsparkasse Düsseldorf (GiroCode account)
|
||||
private const string SellerBic = "DUSSDEDDXXX";
|
||||
|
||||
/// <summary>Builds an <see cref="EInvoice"/> (EN 16931 model) from the Fuchs invoice data.</summary>
|
||||
public static EInvoice BuildEInvoice(FdsInvoiceData invoice)
|
||||
/// <param name="invoice">The Fuchs invoice data.</param>
|
||||
/// <param name="seller">Seller master data; defaults to the built-in Fuchs values when omitted.</param>
|
||||
public static EInvoice BuildEInvoice(FdsInvoiceData invoice, ERechnungSellerSettings? seller = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(invoice);
|
||||
seller ??= new ERechnungSellerSettings();
|
||||
var reg = invoice.InvoiceRegistration;
|
||||
bool reverseCharge = (reg?.getString("InvoiceOptions") ?? "").Contains("§13b", StringComparison.Ordinal);
|
||||
|
||||
@@ -41,11 +35,16 @@ public static class ERechnungMapper
|
||||
InvoiceNumber = NonEmpty(reg?.getString("InvoiceId"), invoice.Id, "ENTWURF"),
|
||||
IssueDate = IssueDate(reg),
|
||||
CurrencyCode = CurrencyCode.Eur,
|
||||
Seller = BuildSeller(),
|
||||
Seller = BuildSeller(seller),
|
||||
Buyer = BuildBuyer(invoice),
|
||||
Payment = BuildPayment(invoice),
|
||||
Payment = BuildPayment(invoice, seller),
|
||||
};
|
||||
|
||||
// B2G: a Leitweg-ID makes this an XRechnung (buyer reference BT-10 is then mandatory).
|
||||
var addr = invoice.RecipientAddress;
|
||||
if (addr is { IsPublicAuthority: true })
|
||||
model.BuyerReference = addr.LeitwegId.Trim();
|
||||
|
||||
string title = reg?.getString("InvoiceTitle") ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
model.Notes.Add(new InvoiceNote(title));
|
||||
@@ -56,23 +55,38 @@ public static class ERechnungMapper
|
||||
model.VatExemptionReasons[VatCategoryCode.ReverseCharge] =
|
||||
new VatExemptionReason("Steuerschuldnerschaft des Leistungsempfängers (§ 13b UStG)");
|
||||
|
||||
// Payment terms (BT-20) + due date (BT-9) — required by BR-CO-25 for a positive amount due.
|
||||
model.PaymentTerms = BuildPaymentTerms(invoice, model.IssueDate);
|
||||
|
||||
// Service date / period (BT-72 or BG-14) from the free-text ProvisionPeriod — clears the
|
||||
// XRechnung recommendation BR-DE-TMP-32 when a date/range can be parsed.
|
||||
ApplyServicePeriod(model, reg?.getString("ProvisionPeriod"));
|
||||
|
||||
return EInvoice.CreateInvoice(model).Recalculate();
|
||||
}
|
||||
|
||||
private static TradeParty BuildSeller()
|
||||
private static TradeParty BuildSeller(ERechnungSellerSettings s)
|
||||
{
|
||||
var seller = new TradeParty
|
||||
{
|
||||
Name = SellerName,
|
||||
Name = s.Name,
|
||||
Address = new PostalAddress
|
||||
{
|
||||
Line1 = SellerStreet,
|
||||
PostalCode = SellerPostalCode,
|
||||
City = SellerCity,
|
||||
Country = CountryCode.Germany,
|
||||
Line1 = NullIfEmpty(s.Street),
|
||||
PostalCode = NullIfEmpty(s.PostalCode),
|
||||
City = NullIfEmpty(s.City),
|
||||
Country = NormalizeCountry(s.CountryCode),
|
||||
},
|
||||
// Seller electronic address (BT-34) + contact (BG-6) — mandatory for XRechnung (BR-DE-5/6/7).
|
||||
ElectronicAddress = new Identifier(s.Email, "EM"),
|
||||
Contact = new TradeContact { Name = s.Name, Telephone = NullIfEmpty(s.Phone), Email = NullIfEmpty(s.Email) },
|
||||
};
|
||||
seller.TaxRegistrations.Add(new TaxRegistration(SellerTaxNumber, TaxRegistrationScheme.LocalTaxNumber));
|
||||
if (!string.IsNullOrWhiteSpace(s.LegalRegistrationId))
|
||||
seller.LegalRegistrationId = new Identifier(s.LegalRegistrationId.Trim()); // BT-30
|
||||
if (!string.IsNullOrWhiteSpace(s.TaxNumber))
|
||||
seller.TaxRegistrations.Add(new TaxRegistration(s.TaxNumber, TaxRegistrationScheme.LocalTaxNumber)); // BT-32
|
||||
if (!string.IsNullOrWhiteSpace(s.VatId))
|
||||
seller.TaxRegistrations.Add(new TaxRegistration(s.VatId, TaxRegistrationScheme.Vat)); // BT-31
|
||||
return seller;
|
||||
}
|
||||
|
||||
@@ -107,25 +121,60 @@ public static class ERechnungMapper
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(addr.VatId))
|
||||
buyer.TaxRegistrations.Add(new TaxRegistration(addr.VatId.Trim(), TaxRegistrationScheme.Vat));
|
||||
// Buyer electronic address (BT-49) — mandatory for XRechnung; use the recipient email.
|
||||
string email = NonEmpty(invoice.InvoiceRegistration?.getString("SendToEmail"), invoice.RawInvoiceEmail);
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
buyer.ElectronicAddress = new Identifier(email.Trim(), "EM");
|
||||
return buyer;
|
||||
}
|
||||
|
||||
private static PaymentInstructions BuildPayment(FdsInvoiceData invoice)
|
||||
private static PaymentInstructions BuildPayment(FdsInvoiceData invoice, ERechnungSellerSettings s)
|
||||
{
|
||||
var payment = new PaymentInstructions
|
||||
{
|
||||
MeansCode = PaymentMeansCode.SepaCreditTransfer,
|
||||
RemittanceInformation = invoice.InvoiceRegistration?.getString("InvoiceId") is { Length: > 0 } inv ? inv : null,
|
||||
};
|
||||
payment.CreditTransfers.Add(new CreditTransferAccount
|
||||
{
|
||||
AccountId = SellerIban,
|
||||
AccountName = SellerName,
|
||||
BankId = SellerBic,
|
||||
});
|
||||
if (!string.IsNullOrWhiteSpace(s.Iban))
|
||||
payment.CreditTransfers.Add(new CreditTransferAccount
|
||||
{
|
||||
AccountId = s.Iban.Replace(" ", ""),
|
||||
AccountName = NullIfEmpty(s.Name),
|
||||
BankId = NullIfEmpty(s.Bic),
|
||||
});
|
||||
return payment;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the free-text service period (e.g. <c>"01.06.2026 - 30.06.2026"</c> or a single
|
||||
/// <c>"18.06.2026"</c>) into the invoicing period (BG-14) or, for a single date, the actual
|
||||
/// delivery date (BT-72). Unparseable text is ignored (the field is only a recommendation).
|
||||
/// </summary>
|
||||
private static void ApplyServicePeriod(Invoice model, string? provisionPeriod)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(provisionPeriod)) return;
|
||||
var dates = System.Text.RegularExpressions.Regex
|
||||
.Matches(provisionPeriod, @"(\d{1,2})\.(\d{1,2})\.(\d{4})|(\d{4})-(\d{2})-(\d{2})")
|
||||
.Select(m => ParseDate(m)).Where(d => d is not null).Select(d => d!.Value).OrderBy(d => d).ToList();
|
||||
if (dates.Count == 0) return;
|
||||
|
||||
if (dates.Count == 1)
|
||||
model.Delivery = new DeliveryInformation { DeliveryDate = dates[0] }; // BT-72
|
||||
else
|
||||
model.InvoicingPeriod = new Period(dates[0], dates[^1]); // BG-14
|
||||
}
|
||||
|
||||
private static DateOnly? ParseDate(System.Text.RegularExpressions.Match m)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (m.Groups[1].Success)
|
||||
return new DateOnly(int.Parse(m.Groups[3].Value), int.Parse(m.Groups[2].Value), int.Parse(m.Groups[1].Value));
|
||||
return new DateOnly(int.Parse(m.Groups[4].Value), int.Parse(m.Groups[5].Value), int.Parse(m.Groups[6].Value));
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static void AddLines(Invoice model, FdsInvoiceData invoice, bool reverseCharge)
|
||||
{
|
||||
int id = 0;
|
||||
@@ -178,6 +227,37 @@ public static class ERechnungMapper
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds payment terms (BT-20 description + BT-9 due date) from the Fuchs payment term token
|
||||
/// (e.g. <c>"10wd"</c> = 10 Werktage, <c>"14d"</c> = 14 Tage). Always returns a term so BR-CO-25
|
||||
/// holds when the amount due is positive.
|
||||
/// </summary>
|
||||
private static PaymentTerms BuildPaymentTerms(FdsInvoiceData invoice, DateOnly issueDate)
|
||||
{
|
||||
string term = NonEmpty(invoice.InvoiceRegistration?.getString("PaymentTerm"), invoice.PaymentTerms).Trim().ToLowerInvariant();
|
||||
var m = System.Text.RegularExpressions.Regex.Match(term, @"^(\d+)\s*(wd|d)?$");
|
||||
if (!m.Success || !int.TryParse(m.Groups[1].Value, out int n) || n <= 0)
|
||||
return new PaymentTerms { Description = "Zahlbar sofort ohne Abzug", DueDate = issueDate };
|
||||
|
||||
bool workingDays = m.Groups[2].Value == "wd";
|
||||
DateOnly due = issueDate;
|
||||
if (workingDays)
|
||||
{
|
||||
int added = 0;
|
||||
while (added < n)
|
||||
{
|
||||
due = due.AddDays(1);
|
||||
if (due.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday) added++;
|
||||
}
|
||||
}
|
||||
else due = issueDate.AddDays(n);
|
||||
|
||||
string desc = workingDays
|
||||
? $"Zahlbar innerhalb von {n} Werktagen ohne Abzug (bis {due:dd.MM.yyyy})"
|
||||
: $"Zahlbar innerhalb von {n} Tagen ohne Abzug (bis {due:dd.MM.yyyy})";
|
||||
return new PaymentTerms { Description = desc, DueDate = due };
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
private static DateOnly IssueDate(GenericObjectDictionary? reg)
|
||||
{
|
||||
|
||||
@@ -21,26 +21,30 @@ public interface IERechnungService
|
||||
/// <summary>
|
||||
/// Builds the hybrid PDF from the raw (non-PDF/A) visual PDF, or returns <see langword="null"/>
|
||||
/// when disabled or on any failure — the caller then falls back to the plain PDF/A so
|
||||
/// invoicing never breaks because of eRechnung production.
|
||||
/// invoicing never breaks because of eRechnung production. When external validation is enabled
|
||||
/// and reports a failure with <c>FailOnError</c>, the hybrid is withheld (null → fallback) too.
|
||||
/// </summary>
|
||||
byte[]? TryBuildHybridPdf(FdsInvoiceData invoice, byte[] rawVisualPdf);
|
||||
Task<byte[]?> TryBuildHybridPdfAsync(FdsInvoiceData invoice, byte[] rawVisualPdf);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IERechnungService"/>
|
||||
public sealed class ERechnungService : IERechnungService
|
||||
{
|
||||
private readonly ERechnungSettings _settings;
|
||||
private readonly IERechnungValidator _validator;
|
||||
private readonly ILogger<ERechnungService> _logger;
|
||||
|
||||
public ERechnungService(IOptions<ERechnungSettings> settings, ILogger<ERechnungService> logger)
|
||||
public ERechnungService(IOptions<ERechnungSettings> settings, IERechnungValidator validator,
|
||||
ILogger<ERechnungService> logger)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_validator = validator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool Enabled => _settings.Enabled;
|
||||
|
||||
public byte[]? TryBuildHybridPdf(FdsInvoiceData invoice, byte[] rawVisualPdf)
|
||||
public async Task<byte[]?> TryBuildHybridPdfAsync(FdsInvoiceData invoice, byte[] rawVisualPdf)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(invoice);
|
||||
if (!_settings.Enabled) return null;
|
||||
@@ -51,8 +55,12 @@ public sealed class ERechnungService : IERechnungService
|
||||
act?.SetTag("fuchs.invoice.id", invoice.Id);
|
||||
try
|
||||
{
|
||||
var profile = ParseProfile(_settings.Profile);
|
||||
var einvoice = ERechnungMapper.BuildEInvoice(invoice);
|
||||
var einvoice = ERechnungMapper.BuildEInvoice(invoice, _settings.Seller);
|
||||
// A Leitweg-ID (buyer reference) marks a B2G invoice → emit the XRechnung profile
|
||||
// (the mandatory form for public authorities); otherwise the configured default (EN 16931).
|
||||
bool isB2G = !string.IsNullOrWhiteSpace(einvoice.Model.BuyerReference);
|
||||
var profile = isB2G ? ZugferdProfile.XRechnung : ParseProfile(_settings.Profile);
|
||||
act?.SetTag("fuchs.erechnung.profile", profile.ToString());
|
||||
var result = einvoice.ToZugferd(profile, rawVisualPdf);
|
||||
|
||||
if (!result.Success)
|
||||
@@ -62,13 +70,39 @@ public sealed class ERechnungService : IERechnungService
|
||||
return null;
|
||||
}
|
||||
if (!result.Validation.IsValid)
|
||||
_logger.LogWarning("eRechnung: invoice {Id} produced with validation findings: {Findings}",
|
||||
_logger.LogWarning("eRechnung: invoice {Id} produced with library validation findings: {Findings}",
|
||||
invoice.Id, result.Validation);
|
||||
|
||||
act?.SetTag("fuchs.erechnung.bytes", result.Value!.Length);
|
||||
byte[] hybrid = result.Value!;
|
||||
act?.SetTag("fuchs.erechnung.bytes", hybrid.Length);
|
||||
_logger.LogInformation("eRechnung: hybrid PDF/A-3 built for invoice {Id} ({Profile}, {Bytes} bytes, {Ms} ms)",
|
||||
invoice.Id, profile, result.Value.Length, sw.ElapsedMilliseconds);
|
||||
return result.Value;
|
||||
invoice.Id, profile, hybrid.Length, sw.ElapsedMilliseconds);
|
||||
|
||||
// External formal verification (EN 16931 XML + PDF/A-3 via the ProcessWeb eInvoice service).
|
||||
if (_validator.Enabled)
|
||||
{
|
||||
var v = await _validator.ValidatePdfAsync(hybrid);
|
||||
act?.SetTag("fuchs.erechnung.valid", v.IsValid);
|
||||
act?.SetTag("fuchs.erechnung.pdfa", v.PdfACompliant);
|
||||
if (v.IsValid)
|
||||
_logger.LogInformation("eRechnung: invoice {Id} externally validated OK ({Summary})", invoice.Id, v.Summary);
|
||||
else if (v.HasHardError)
|
||||
{
|
||||
_logger.LogWarning("eRechnung: invoice {Id} has validation errors (pdfA={PdfA}, xmlErrors={Errors}): {Summary}",
|
||||
invoice.Id, v.PdfACompliant, v.XmlErrorCount, v.Summary);
|
||||
if (_settings.Validation.FailOnError)
|
||||
{
|
||||
_logger.LogError("eRechnung: withholding hybrid for invoice {Id} (FailOnError); falling back to plain PDF/A", invoice.Id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else
|
||||
// Reached but not strictly valid without hard errors — e.g. the validator's XML
|
||||
// scenario set does not cover our EN 16931 ZUGFeRD. Informational only.
|
||||
_logger.LogInformation("eRechnung: invoice {Id} — PDF/A ok; XML scenario not matched by validator (no errors). {Summary}",
|
||||
invoice.Id, v.Summary);
|
||||
}
|
||||
return hybrid;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -21,10 +21,57 @@ public sealed class ERechnungSettings
|
||||
/// for full booking; MINIMUM/BASIC WL are intentionally not offered).</summary>
|
||||
public string Profile { get; set; } = "EN16931";
|
||||
|
||||
/// <summary>Seller (Fuchs) master data mapped into every eRechnung (BG-4).</summary>
|
||||
public ERechnungSellerSettings Seller { get; set; } = new();
|
||||
|
||||
/// <summary>Formal-conformance validation settings (PDF/A-3 via veraPDF, ZUGFeRD rules).</summary>
|
||||
public ERechnungValidationSettings Validation { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The seller (Fuchs) master data written into every eRechnung, bound from
|
||||
/// <c>Fuchs:ERechnung:Seller</c>. Defaults mirror the FuchsPdf letterhead so the mapping keeps
|
||||
/// working if the section is absent; override in <c>appsettings.json</c>.
|
||||
/// </summary>
|
||||
public sealed class ERechnungSellerSettings
|
||||
{
|
||||
/// <summary>Legal / trading name (BT-27).</summary>
|
||||
public string Name { get; set; } = "Sebastian Fuchs GmbH & Co. KG";
|
||||
|
||||
/// <summary>Street and house number (BT-35).</summary>
|
||||
public string Street { get; set; } = "Germaniastraße 15";
|
||||
|
||||
/// <summary>Post code (BT-38).</summary>
|
||||
public string PostalCode { get; set; } = "40223";
|
||||
|
||||
/// <summary>City (BT-37).</summary>
|
||||
public string City { get; set; } = "Düsseldorf";
|
||||
|
||||
/// <summary>ISO 3166-1 alpha-2 country code (BT-40).</summary>
|
||||
public string CountryCode { get; set; } = "DE";
|
||||
|
||||
/// <summary>Steuernummer / local tax number (BT-32).</summary>
|
||||
public string TaxNumber { get; set; } = "106/5849/2962";
|
||||
|
||||
/// <summary>USt-IdNr / VAT identifier (BT-31) — required by BR-CO-26.</summary>
|
||||
public string VatId { get; set; } = "DE286366012";
|
||||
|
||||
/// <summary>Legal registration id / Handelsregisternummer (BT-30). Optional.</summary>
|
||||
public string LegalRegistrationId { get; set; } = "";
|
||||
|
||||
/// <summary>Payment IBAN (BT-84).</summary>
|
||||
public string Iban { get; set; } = "DE76300501100045014800";
|
||||
|
||||
/// <summary>Payment BIC (BT-86).</summary>
|
||||
public string Bic { get; set; } = "DUSSDEDDXXX";
|
||||
|
||||
/// <summary>Contact / electronic-address e-mail (BT-34 / BT-43).</summary>
|
||||
public string Email { get; set; } = "info@sanitaerfuchs.de";
|
||||
|
||||
/// <summary>Contact telephone (BT-42).</summary>
|
||||
public string Phone { get; set; } = "0211 - 31 07 222";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Settings for the external eRechnung validation service (veraPDF for PDF/A-3 and a
|
||||
/// ZUGFeRD/EN 16931 validator), bound from "Fuchs:ERechnung:Validation".
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>The outcome of an external eRechnung validation (ZUGFeRD/EN 16931 XML + PDF/A-3).</summary>
|
||||
/// <param name="Configured">False when no validation service URL is set (validation skipped).</param>
|
||||
/// <param name="Reached">False when the service could not be contacted / returned an error.</param>
|
||||
/// <param name="XmlValid">Whether the embedded XML matched a scenario and passed its rules.</param>
|
||||
/// <param name="PdfACompliant">Whether the PDF/A-3 layer passed (veraPDF).</param>
|
||||
/// <param name="XmlErrorCount">Number of hard XML rule errors reported.</param>
|
||||
/// <param name="ScenarioMatched">Whether the validator had a scenario for this document type.</param>
|
||||
/// <param name="Summary">Short human-readable summary from the service.</param>
|
||||
public sealed record ERechnungValidationResult(
|
||||
bool Configured, bool Reached, bool XmlValid, bool PdfACompliant,
|
||||
int XmlErrorCount, bool ScenarioMatched, string Summary)
|
||||
{
|
||||
/// <summary>True only when the service was reached and both the XML and the PDF/A-3 passed.</summary>
|
||||
public bool IsValid => Reached && XmlValid && PdfACompliant;
|
||||
|
||||
/// <summary>
|
||||
/// A hard defect: an unreachable service is <b>not</b> one, but a non-compliant PDF/A-3 or an
|
||||
/// XML with actual rule errors is. A pure <see cref="ScenarioMatched"/>=false with zero errors
|
||||
/// (e.g. an EN 16931 ZUGFeRD checked by an XRechnung-only scenario set) is <b>not</b> a hard
|
||||
/// error — it is a validator scope limitation, so it never withholds the invoice.
|
||||
/// </summary>
|
||||
public bool HasHardError => Reached && (!PdfACompliant || XmlErrorCount > 0);
|
||||
|
||||
public static ERechnungValidationResult NotConfigured { get; } =
|
||||
new(false, false, false, false, 0, false, "Validierungsdienst nicht konfiguriert (übersprungen).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a produced ZUGFeRD/Factur-X hybrid against the external ProcessWeb eInvoice service
|
||||
/// (<c>POST {ServiceUrl}/validatepdf</c>, raw <c>application/pdf</c> body — checks the embedded
|
||||
/// EN 16931 XML <b>and</b> PDF/A-3 in one call). Configured via <c>Fuchs:ERechnung:Validation</c>.
|
||||
/// </summary>
|
||||
public interface IERechnungValidator
|
||||
{
|
||||
/// <summary>Whether external validation is switched on and a service URL is configured.</summary>
|
||||
bool Enabled { get; }
|
||||
|
||||
/// <summary>Validates the hybrid PDF; never throws (failures are reported in the result).</summary>
|
||||
Task<ERechnungValidationResult> ValidatePdfAsync(byte[] hybridPdf, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IERechnungValidator"/>
|
||||
public sealed class ProcessWebERechnungValidator : IERechnungValidator
|
||||
{
|
||||
/// <summary>Named <see cref="HttpClient"/> registered in <c>Program.cs</c>.</summary>
|
||||
public const string HttpClientName = "eInvoiceValidator";
|
||||
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly ERechnungValidationSettings _settings;
|
||||
private readonly ILogger<ProcessWebERechnungValidator> _logger;
|
||||
|
||||
public ProcessWebERechnungValidator(IHttpClientFactory httpFactory,
|
||||
IOptions<ERechnungSettings> settings, ILogger<ProcessWebERechnungValidator> logger)
|
||||
{
|
||||
_httpFactory = httpFactory;
|
||||
_settings = settings.Value.Validation;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool Enabled => _settings.Enabled && !string.IsNullOrWhiteSpace(_settings.ServiceUrl);
|
||||
|
||||
public async Task<ERechnungValidationResult> ValidatePdfAsync(byte[] hybridPdf, CancellationToken ct = default)
|
||||
{
|
||||
if (!Enabled) return ERechnungValidationResult.NotConfigured;
|
||||
if (hybridPdf is null || hybridPdf.Length == 0)
|
||||
return new ERechnungValidationResult(true, false, false, false, 0, false, "Leeres PDF – nicht validiert.");
|
||||
|
||||
var url = _settings.ServiceUrl.TrimEnd('/') + "/validatepdf";
|
||||
try
|
||||
{
|
||||
using var content = new ByteArrayContent(hybridPdf);
|
||||
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||
using var client = _httpFactory.CreateClient(HttpClientName);
|
||||
|
||||
using var response = await client.PostAsync(url, content, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("eRechnung validation: {Url} returned {Status}: {Body}", url, (int)response.StatusCode, Truncate(body));
|
||||
return new ERechnungValidationResult(true, false, false, false, 0, false, $"HTTP {(int)response.StatusCode} vom Validierungsdienst.");
|
||||
}
|
||||
return Parse(body);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "eRechnung validation: could not reach {Url}", url);
|
||||
return new ERechnungValidationResult(true, false, false, false, 0, false, "Validierungsdienst nicht erreichbar.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Parses the <c>validatepdf</c> response (<c>{ isValid, summary, xml:{…}, pdfa:{ isCompliant } }</c>).</summary>
|
||||
private static ERechnungValidationResult Parse(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
root.TryGetProperty("xml", out var xml);
|
||||
bool xmlValid = xml.ValueKind == JsonValueKind.Object && GetBool(xml, "isValid");
|
||||
bool scenarioMatched = xml.ValueKind == JsonValueKind.Object && GetBool(xml, "scenarioMatched");
|
||||
int errorCount = xml.ValueKind == JsonValueKind.Object && xml.TryGetProperty("errorCount", out var ec)
|
||||
&& ec.ValueKind == JsonValueKind.Number ? ec.GetInt32() : 0;
|
||||
bool pdfa = root.TryGetProperty("pdfa", out var p) && GetBool(p, "isCompliant");
|
||||
string summary = root.TryGetProperty("summary", out var s) && s.ValueKind == JsonValueKind.String
|
||||
? s.GetString() ?? "" : "";
|
||||
return new ERechnungValidationResult(true, true, xmlValid, pdfa, errorCount, scenarioMatched, summary);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new ERechnungValidationResult(true, false, false, false, 0, false, "Antwort des Validierungsdienstes nicht lesbar.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool GetBool(JsonElement obj, string name)
|
||||
=> obj.TryGetProperty(name, out var v) && (v.ValueKind == JsonValueKind.True
|
||||
|| (v.ValueKind == JsonValueKind.String && bool.TryParse(v.GetString(), out var b) && b));
|
||||
|
||||
private static string Truncate(string s) => s.Length <= 300 ? s : s[..300];
|
||||
}
|
||||
@@ -35,9 +35,18 @@ public sealed class InvoiceRecipientAddress
|
||||
/// <summary>Buyer VAT identifier (BT-48). Empty for a private person (B2C).</summary>
|
||||
public string VatId { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Leitweg-ID / buyer reference (BT-10). When set, the invoice is treated as B2G and emitted
|
||||
/// as <b>XRechnung</b> (the mandatory form for German public authorities); empty for B2B/B2C.
|
||||
/// </summary>
|
||||
public string LeitwegId { get; set; } = "";
|
||||
|
||||
/// <summary>True when no VAT id is set — a private person / B2C recipient.</summary>
|
||||
public bool IsPrivatePerson => string.IsNullOrWhiteSpace(VatId);
|
||||
|
||||
/// <summary>True when a Leitweg-ID is present — a B2G recipient (→ XRechnung).</summary>
|
||||
public bool IsPublicAuthority => !string.IsNullOrWhiteSpace(LeitwegId);
|
||||
|
||||
/// <summary>Reads a structured address from a JSON object (tolerant of missing keys).</summary>
|
||||
public static InvoiceRecipientAddress FromJson(JObject? o)
|
||||
{
|
||||
@@ -52,6 +61,7 @@ public sealed class InvoiceRecipientAddress
|
||||
City = S(o, "city", "ort"),
|
||||
CountryCode = S(o, "countryCode", "country").ToUpperInvariant() is { Length: > 0 } cc ? cc : "DE",
|
||||
VatId = S(o, "vatId", "ustid"),
|
||||
LeitwegId = S(o, "leitwegId", "leitweg", "buyerReference"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +88,7 @@ public sealed class InvoiceRecipientAddress
|
||||
["city"] = City,
|
||||
["countryCode"] = CountryCode,
|
||||
["vatId"] = VatId,
|
||||
["leitwegId"] = LeitwegId,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -284,7 +284,7 @@ public class InvoiceService : IInvoiceService
|
||||
return doc;
|
||||
}
|
||||
|
||||
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
|
||||
public async Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
|
||||
{
|
||||
var doc = GenerateInvoicePdf(invoice, draft);
|
||||
// Finalized invoices are emitted as a ZUGFeRD/Factur-X hybrid when eRechnung is enabled:
|
||||
@@ -292,10 +292,10 @@ public class InvoiceService : IInvoiceService
|
||||
// PDF/A-3 layer (ADR 0012). Drafts/previews keep the plain PDF/A. Any failure falls back.
|
||||
if (!draft && _erechnung.Enabled)
|
||||
{
|
||||
var hybrid = _erechnung.TryBuildHybridPdf(invoice, _pdf.DocToPdfBytesRaw(doc));
|
||||
if (hybrid is { Length: > 0 }) return Task.FromResult(hybrid);
|
||||
var hybrid = await _erechnung.TryBuildHybridPdfAsync(invoice, _pdf.DocToPdfBytesRaw(doc));
|
||||
if (hybrid is { Length: > 0 }) return hybrid;
|
||||
}
|
||||
return Task.FromResult(_pdf.DocToPdfBytes(doc));
|
||||
return _pdf.DocToPdfBytes(doc);
|
||||
}
|
||||
|
||||
public async Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData invoice, bool draft,
|
||||
|
||||
Reference in New Issue
Block a user