Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
235c9587f0 | ||
|
|
00e72c96d4 |
@@ -18,7 +18,7 @@ namespace Fuchs.Tests;
|
|||||||
public class ERechnungMapperTests
|
public class ERechnungMapperTests
|
||||||
{
|
{
|
||||||
private static FdsInvoiceData BuildInvoice(string sendToAddressJson, string vat = "19",
|
private static FdsInvoiceData BuildInvoice(string sendToAddressJson, string vat = "19",
|
||||||
string invoiceOptions = "", bool withItem = true)
|
string invoiceOptions = "", bool withItem = true, string provisionPeriod = "")
|
||||||
{
|
{
|
||||||
var items = withItem
|
var items = withItem
|
||||||
? "[{'id':'900','type':'material','title':'Reparatur','desc':'Vor Ort','qty':2,'price_net':50,'total_net':100,'vat':'" + vat + "'}]"
|
? "[{'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,
|
["InvoiceOptions"] = invoiceOptions,
|
||||||
["InvoiceBalance_net"] = "100",
|
["InvoiceBalance_net"] = "100",
|
||||||
["InvoiceVAT_1"] = vat,
|
["InvoiceVAT_1"] = vat,
|
||||||
|
["ProvisionPeriod"] = provisionPeriod,
|
||||||
["SendToAddressJson"] = sendToAddressJson,
|
["SendToAddressJson"] = sendToAddressJson,
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -120,4 +121,72 @@ public class ERechnungMapperTests
|
|||||||
[InlineData("", "DE")]
|
[InlineData("", "DE")]
|
||||||
public void NormalizeCountry_MapsNamesAndCodes(string raw, string expected)
|
public void NormalizeCountry_MapsNamesAndCodes(string raw, string expected)
|
||||||
=> Assert.Equal(expected, ERechnungMapper.NormalizeCountry(raw).Value);
|
=> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,15 +46,27 @@ Editor (structured recipient dialog)
|
|||||||
(no VAT id) is fully valid — B2C stays effortless. The free-text `SendToAddress` is composed
|
(no VAT id) is fully valid — B2C stays effortless. The free-text `SendToAddress` is composed
|
||||||
from it so the PDF layout is unchanged.
|
from it so the PDF layout is unchanged.
|
||||||
- **Mapping.** `ERechnungMapper` maps the Fuchs invoice to the EN 16931 model: seller = Fuchs
|
- **Mapping.** `ERechnungMapper` maps the Fuchs invoice to the EN 16931 model: seller = Fuchs
|
||||||
(constants mirroring the FuchsPdf letterhead — name, Steuernummer BT-32, IBAN/BIC), buyer =
|
(from `Fuchs:ERechnung:Seller` config — name, address, Steuernummer BT-32, **USt-IdNr BT-31**,
|
||||||
the structured recipient, lines from the invoice items, §13b → reverse charge (category AE +
|
IBAN/BIC, contact; defaults mirror the FuchsPdf letterhead), buyer = the structured recipient,
|
||||||
exemption reason). VAT breakdown and totals are recomputed by the library.
|
lines from the invoice items, §13b → reverse charge (category AE + exemption reason), payment
|
||||||
|
terms BT-20/BT-9, and the service date/period (BT-72 or BG-14) parsed from the structured
|
||||||
|
`ProvisionPeriod`. VAT breakdown and totals are recomputed by the library.
|
||||||
|
- **Profile selection (B2B/B2C vs B2G).** Default is ZUGFeRD **EN 16931** (DATEV). When the
|
||||||
|
recipient carries a **Leitweg-ID** (`InvoiceRecipientAddress.LeitwegId` → `BuyerReference` BT-10),
|
||||||
|
the invoice is B2G and emitted as **XRechnung** (`ZugferdProfile.XRechnung`, embedded
|
||||||
|
`xrechnung.xml`); the mapper then also fills the seller electronic address/contact (BT-34/BG-6)
|
||||||
|
and buyer electronic address (BT-49) that XRechnung requires.
|
||||||
- **Feature flag & fallback.** Emission is gated by `Fuchs:ERechnung:Enabled` (off until fully
|
- **Feature flag & fallback.** Emission is gated by `Fuchs:ERechnung:Enabled` (off until fully
|
||||||
validated). `IERechnungService` returns `null` on disable **or any failure**, so
|
validated). `IERechnungService` returns `null` on disable **or any failure**, so
|
||||||
`RenderInvoicePdfBytesAsync` falls back to the plain Spire PDF/A — invoicing never breaks.
|
`RenderInvoicePdfBytesAsync` falls back to the plain Spire PDF/A — invoicing never breaks.
|
||||||
- **Formal verification.** `Fuchs:ERechnung:Validation:ServiceUrl` is the seam for an external
|
- **Formal verification.** `Fuchs:ERechnung:Validation` calls the ProcessWeb eInvoice service
|
||||||
online veraPDF (PDF/A-3) + ZUGFeRD validator; until the URL is provisioned, verification is
|
(`POST {ServiceUrl}/validatepdf`, raw `application/pdf`) which checks the EN 16931 XML **and**
|
||||||
reported as not-configured.
|
PDF/A-3 (veraPDF) in one call. Both the EN 16931-ZUGFeRD and the XRechnung 3.0 output are
|
||||||
|
externally **ACCEPTED** (0 errors) and **PDF/A-3B COMPLIANT**. Getting there required fixing two
|
||||||
|
eRechnungLib defects (CII root children must be `rsm:` not `ram:`; embedded-file `/Subtype` MIME
|
||||||
|
encoding) and completing the mapper (seller VAT id BT-31, payment terms BT-20/BT-9). The
|
||||||
|
validator's target feature scope is documented in
|
||||||
|
[`../eRechnung-Validator-Requirements.md`](../eRechnung-Validator-Requirements.md).
|
||||||
|
|
||||||
## Key files
|
## Key files
|
||||||
- `Fuchs/Services/InvoiceRecipientAddress.cs` — structured buyer address, composition, conformity.
|
- `Fuchs/Services/InvoiceRecipientAddress.cs` — structured buyer address, composition, conformity.
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ payload; see `EVAL_live_invoice_editing.md` for the rationale.
|
|||||||
is edited via a **structured dialog** (`$inv.eAddress`: name, street, PLZ, city,
|
is edited via a **structured dialog** (`$inv.eAddress`: name, street, PLZ, city,
|
||||||
country, optional VAT id) prefilled from `fds__prepInvoice`'s `invoiceaddressData`;
|
country, optional VAT id) prefilled from `fds__prepInvoice`'s `invoiceaddressData`;
|
||||||
it drives the EN 16931 eRechnung and composes the free-text `SendToAddress` for the
|
it drives the EN 16931 eRechnung and composes the free-text `SendToAddress` for the
|
||||||
PDF (see [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md)).
|
PDF (see [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md)). The **service
|
||||||
|
date/period** (Leistungsdatum/-zeitraum) is likewise a structured German-date dialog
|
||||||
|
(`$inv.eProvisionPeriod`) — a single date or a from/to range — mapped to BT-72 / BG-14.
|
||||||
- **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns.
|
- **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns.
|
||||||
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
|
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
|
||||||
(default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational
|
(default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Anforderungen an den eInvoice-Validierungsdienst
|
||||||
|
|
||||||
|
**Adressat:** Betreiber des Validierungsdienstes `https://api.processweb.de/api/eInvoice`
|
||||||
|
**Kontext:** Das Fuchs-Intranet erzeugt Rechnungen als ZUGFeRD/Factur-X- bzw. XRechnung-Hybrid
|
||||||
|
(PDF/A-3 mit eingebetteter CII-XML) und ruft nach der Erzeugung `POST /validatepdf` auf, um
|
||||||
|
**formale PDF/A-3-Konformität** und **EN 16931-/XRechnung-Regelkonformität** zu prüfen.
|
||||||
|
|
||||||
|
**Zielbild:** Der Validator soll **maximalen Funktionsumfang** haben — jede in Deutschland/EU
|
||||||
|
praktisch vorkommende E-Rechnung (alle gängigen Syntaxen, Profile und Versionen) erkennen,
|
||||||
|
korrekt klassifizieren und gegen die passenden Regelwerke prüfen.
|
||||||
|
|
||||||
|
> **Status 2026-07:** Die ursprüngliche Kernlücke (CII-Dokumente wurden nicht klassifiziert) ist
|
||||||
|
> behoben — sowohl EN 16931-ZUGFeRD als auch XRechnung 3.0 (CII) werden inzwischen erkannt und
|
||||||
|
> geprüft (`scenarioMatched=true`, angereichertes Response-Schema). Dieses Dokument bleibt als
|
||||||
|
> Referenz für den angestrebten **vollen** Funktionsumfang (Abschnitt 4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Referenz — was Fuchs emittiert
|
||||||
|
|
||||||
|
| Profil | Einbettung | GuidelineSpecifiedDocumentContextParameter/ID | Einsatz |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ZUGFeRD EN 16931 (COMFORT) | `factur-x.xml` | `urn:cen.eu:en16931:2017` | B2B/B2C, DATEV (Standard) |
|
||||||
|
| XRechnung 3.0 (CII) | `xrechnung.xml` | `urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0` | B2G (Leitweg-ID vorhanden) |
|
||||||
|
|
||||||
|
Beide sind PDF/A-3 (veraPDF-COMPLIANT). Profilwahl automatisch: **Leitweg-ID vorhanden →
|
||||||
|
XRechnung**, sonst **EN 16931-ZUGFeRD**.
|
||||||
|
|
||||||
|
## 2. Kern-Anforderungen (umgesetzt, als Regressionsschutz dokumentiert)
|
||||||
|
|
||||||
|
### R1 — XRechnung 3.0 in CII matchen (B2G)
|
||||||
|
- Match-Kriterium (CII): `rsm:CrossIndustryInvoice/rsm:ExchangedDocumentContext/`
|
||||||
|
`ram:GuidelineSpecifiedDocumentContextParameter/ram:ID` =
|
||||||
|
`urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0`
|
||||||
|
- Erwartung: `documentType` gesetzt, `scenarioMatched=true`, EN 16931- **und** XRechnung-Schematron.
|
||||||
|
|
||||||
|
### R2 — Reines EN 16931 (CII) matchen (ZUGFeRD/DATEV)
|
||||||
|
- Match-Kriterium (CII): `…/ram:GuidelineSpecifiedDocumentContextParameter/ram:ID` =
|
||||||
|
`urn:cen.eu:en16931:2017` (ohne XRechnung-CIUS-Zusatz).
|
||||||
|
- Prüftiefe: **nur** das EN 16931-Kern-Schematron (nicht die XRechnung-CIUS-Regeln).
|
||||||
|
|
||||||
|
### R3 — Einbettung aus dem Hybrid-PDF robust extrahieren
|
||||||
|
`/validatepdf` muss die CII-Rechnungs-XML unabhängig vom Dateinamen aus dem `/AF`-Array ziehen
|
||||||
|
(`factur-x.xml`, `xrechnung.xml`, Altname `zugferd-invoice.xml`). Das Match erfolgt über den
|
||||||
|
XML-Inhalt/die CustomizationID, nicht über den Dateinamen.
|
||||||
|
|
||||||
|
> Historischer Hinweis: Solange die drei CII-Wurzelkinder fälschlich im `ram:`- statt
|
||||||
|
> `rsm:`-Namespace standen (Fuchs-seitiger Bug, behoben), konnte kein Validator die CustomizationID
|
||||||
|
> extrahieren. Der Match muss auf `rsm:ExchangedDocumentContext` greifen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. (frei)
|
||||||
|
|
||||||
|
## 4. Maximaler Funktionsumfang
|
||||||
|
|
||||||
|
### 4.1 Support-Matrix (Syntax × Profil × Version)
|
||||||
|
|
||||||
|
| Standard / Profil | Syntax | Versionen | Regelwerk |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **EN 16931** (Kern) | CII, UBL | :2017 (+ Amdt.) | EN16931-Schematron (ConnectingEurope) |
|
||||||
|
| **XRechnung** (CIUS) | CII, UBL | 2.0 – 2.3, **3.0.x**, (4.0) | EN16931 + XRechnung-Schematron (KoSIT) |
|
||||||
|
| **ZUGFeRD / Factur-X** | CII | 2.0 – **2.4** (Factur-X 1.0/1.07/1.08) | EN16931 (BASIC/COMFORT), EXTENDED |
|
||||||
|
| ZUGFeRD-Profile | CII | MINIMUM, BASIC WL, BASIC, EN16931, EXTENDED | profilabhängig |
|
||||||
|
| **Peppol BIS Billing 3.0** | CII, UBL | 3.0.x | EN16931 + Peppol-Schematron |
|
||||||
|
| Dokumentarten | — | Rechnung (380), Gutschrift (381), Korrektur (384), Storno | UNCL1001 |
|
||||||
|
|
||||||
|
> Mindest-Priorität: **XRechnung 2.x** und **ZUGFeRD alle 2.x-Profile**; **Peppol BIS 3.0** und
|
||||||
|
> **XRechnung 4.0** als Ausbaustufe.
|
||||||
|
|
||||||
|
### 4.2 Validierungsschichten (je Dokument, getrennt ausgewiesen)
|
||||||
|
|
||||||
|
1. **Syntax / XSD** — CII (D16B/D22B/D25A) bzw. UBL (2.1/2.5).
|
||||||
|
2. **EN 16931-Schematron** — BR-*, BR-CO-*, BR-DEC-*.
|
||||||
|
3. **CIUS-Schematron** — XRechnung (BR-DE-*) bzw. Peppol (PEPPOL-*).
|
||||||
|
4. **Codelisten** — UNCL/EAS/ISO (Währung, Land, Einheit, USt-Kategorie, Zahlungsmittel).
|
||||||
|
5. **Rechen-/Konsistenzprüfung** — Summen, USt-Aufteilung.
|
||||||
|
6. **PDF/A** (veraPDF) — Flavour wählbar (`3b` Default; auch `3u`, `2b`, `1b`).
|
||||||
|
7. **ZUGFeRD/Factur-X-Container** — `/AF`-Verknüpfung, `/AFRelationship`, Subtype `text/xml`,
|
||||||
|
XMP-`fx:*`-Metadaten und **Konsistenz** zwischen XMP-ConformanceLevel und Guideline/CustomizationID.
|
||||||
|
|
||||||
|
### 4.3 Auto-Erkennung (ohne Vorgabe durch den Aufrufer)
|
||||||
|
|
||||||
|
- **Syntax** aus dem Wurzelelement (`rsm:CrossIndustryInvoice` → CII; UBL `Invoice`/`CreditNote`).
|
||||||
|
- **Profil/Version** aus `GuidelineSpecifiedDocumentContextParameter/ram:ID` (CII) bzw.
|
||||||
|
`cbc:CustomizationID` + `cbc:ProfileID` (UBL).
|
||||||
|
- **Dokumentart** aus `TypeCode` (BT-3).
|
||||||
|
- Strengstes zutreffendes CIUS automatisch wählen; reines EN 16931 nur Kernregeln.
|
||||||
|
|
||||||
|
### 4.4 Erweiterte API-Endpunkte
|
||||||
|
|
||||||
|
Bestehend: `verify`, `validate`, `validatepdf`, `validatepdfa`, `doc`, `schema`, `mcp`. Ergänzen:
|
||||||
|
|
||||||
|
- **`POST /detect`** — nur Klassifikation (Syntax/Profil/Version/Dokumentart).
|
||||||
|
- **`POST /validate` mit UBL** — `application/xml` UBL akzeptieren (nicht nur CII).
|
||||||
|
- **`POST /validatebatch`** — mehrere Dokumente (multipart oder ZIP).
|
||||||
|
- **Report-Formate** über `Accept`/`?format=`: `json` (Default), `svrl`, `html`.
|
||||||
|
- **`flavour`-Parameter** auch bei `validatepdf` durchreichen.
|
||||||
|
- Weiterhin **HTTP 200** bei fachlicher Ablehnung; echte Transportfehler als 400/415/502/504.
|
||||||
|
|
||||||
|
### 4.5 Angereichertes Response-Schema
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"xml": {
|
||||||
|
"syntax": "CII|UBL",
|
||||||
|
"standard": "EN16931|XRechnung|ZUGFeRD|Peppol",
|
||||||
|
"profile": "EN16931|EXTENDED|XRECHNUNG|BASIC|…",
|
||||||
|
"version": "3.0.2",
|
||||||
|
"documentType": "XRechnung 3.0 (CII)",
|
||||||
|
"customizationId": "urn:…xrechnung_3.0",
|
||||||
|
"scenarioMatched": true,
|
||||||
|
"documentData": { "invoiceId","issueDate","seller","buyer","leitwegId","totalGross","currency" },
|
||||||
|
"layers": [ { "id":"xsd|en16931|cius|codelist|calc", "name","valid","errorCount","warningCount" } ],
|
||||||
|
"messages": [ { "level":"error|warning|information","code","rule","message","location","clause","businessTerm" } ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `level` konsequent aus dem SVRL (`fatal`→error, sonst warning).
|
||||||
|
- `location` als XPath **und**, wo möglich, als Business Term (BT-/BG-Nummer).
|
||||||
|
- `recommendation` = `accept`, wenn `errorCount=0` (Warnungen erlaubt).
|
||||||
|
|
||||||
|
### 4.6 Betrieb / Nicht-funktional
|
||||||
|
|
||||||
|
- **Aktuelle Artefakt-Stände**: KoSIT (XRechnung 2.x/3.0.x/4.0), eInvoicing-EN16931, Peppol,
|
||||||
|
veraPDF — mit ausgewiesenen Versionen im Report (`engine`, `scenarioVersion`, `rulesetVersion`).
|
||||||
|
- **Health** (`/verify`) meldet geladene Szenario-/Ruleset-Versionen + Zustand jedes Sub-Validators.
|
||||||
|
- Robuste Größen-/Timeout-Grenzen dokumentiert; klare 413/504 statt stiller Fehler.
|
||||||
|
|
||||||
|
## 5. Abnahmekriterien
|
||||||
|
|
||||||
|
1. **ZUGFeRD EN 16931** (`factur-x.xml`, `urn:cen.eu:en16931:2017`): `xml.scenarioMatched=true`,
|
||||||
|
`documentType` ≈ `"EN16931 (CII)"`, `pdfa.isCompliant=true`, bei sauberem Beleg `errorCount=0`.
|
||||||
|
2. **XRechnung 3.0** (`xrechnung.xml`, `…xrechnung_3.0`, mit Leitweg-ID): `scenarioMatched=true`,
|
||||||
|
`documentType` ≈ `"XRechnung 3.0 (CII)"`, `pdfa.isCompliant=true`.
|
||||||
|
3. **Auto-Erkennung** (`POST /detect`): klassifiziert beide korrekt (Syntax/Profil/Version).
|
||||||
|
4. **UBL-XRechnung** (Fremdbeleg): wird erkannt und geprüft.
|
||||||
|
5. **PDF/A**: bleibt COMPLIANT (Flavour `3b`).
|
||||||
|
|
||||||
|
## 6. Fuchs-seitiges Verhalten
|
||||||
|
|
||||||
|
Der Fuchs-Client (`ProcessWebERechnungValidator`) wertet einen reinen **Szenario-Mismatch mit
|
||||||
|
`errorCount=0` nicht als harten Fehler** (die Rechnung wird nicht zurückgehalten); echte PDF/A-
|
||||||
|
oder XML-Regelfehler dagegen schon (bei `FailOnError=true` wird der Hybrid zurückgehalten →
|
||||||
|
Fallback auf Plain-PDF/A).
|
||||||
@@ -124,6 +124,9 @@ public class Program
|
|||||||
// eRechnung (ZUGFeRD/Factur-X) output + external formal-conformance validation seam.
|
// eRechnung (ZUGFeRD/Factur-X) output + external formal-conformance validation seam.
|
||||||
builder.Services.Configure<ERechnungSettings>(builder.Configuration.GetSection("Fuchs:ERechnung"));
|
builder.Services.Configure<ERechnungSettings>(builder.Configuration.GetSection("Fuchs:ERechnung"));
|
||||||
builder.Services.AddSingleton<IERechnungService, ERechnungService>(); // stateless: maps + embeds ZUGFeRD
|
builder.Services.AddSingleton<IERechnungService, ERechnungService>(); // stateless: maps + embeds ZUGFeRD
|
||||||
|
builder.Services.AddSingleton<IERechnungValidator, ProcessWebERechnungValidator>(); // external EN16931 + PDF/A-3 check
|
||||||
|
builder.Services.AddHttpClient(ProcessWebERechnungValidator.HttpClientName,
|
||||||
|
c => c.Timeout = TimeSpan.FromSeconds(90));
|
||||||
builder.Services.AddHttpClient("ProcessWebMailer");
|
builder.Services.AddHttpClient("ProcessWebMailer");
|
||||||
builder.Services.AddScoped<IComService, ProcessWebComService>();
|
builder.Services.AddScoped<IComService, ProcessWebComService>();
|
||||||
// Holds the one-shot startup self-test result for the lifetime of the process so the Admin
|
// Holds the one-shot startup self-test result for the lifetime of the process so the Admin
|
||||||
|
|||||||
@@ -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).
|
/// <c>eRechnungLib</c>, so it can be emitted as a ZUGFeRD/Factur-X hybrid (see ADR 0005/0012).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The seller (<c>BG-4</c>) is Fuchs itself; its master data currently lives as constants in
|
/// The seller (<c>BG-4</c>) is Fuchs itself; its master data comes from
|
||||||
/// <c>FuchsPdf</c> (letterhead, Steuernummer, bank) and is mirrored here — a future change should
|
/// <see cref="ERechnungSellerSettings"/> (<c>Fuchs:ERechnung:Seller</c> in appsettings, defaults
|
||||||
/// lift both into shared configuration. The buyer (<c>BG-7</c>) comes from the structured
|
/// 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
|
/// <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.
|
/// no buyer tax registration. Amounts/VAT are recomputed by the library from the mapped lines.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class ERechnungMapper
|
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>
|
/// <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);
|
ArgumentNullException.ThrowIfNull(invoice);
|
||||||
|
seller ??= new ERechnungSellerSettings();
|
||||||
var reg = invoice.InvoiceRegistration;
|
var reg = invoice.InvoiceRegistration;
|
||||||
bool reverseCharge = (reg?.getString("InvoiceOptions") ?? "").Contains("§13b", StringComparison.Ordinal);
|
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"),
|
InvoiceNumber = NonEmpty(reg?.getString("InvoiceId"), invoice.Id, "ENTWURF"),
|
||||||
IssueDate = IssueDate(reg),
|
IssueDate = IssueDate(reg),
|
||||||
CurrencyCode = CurrencyCode.Eur,
|
CurrencyCode = CurrencyCode.Eur,
|
||||||
Seller = BuildSeller(),
|
Seller = BuildSeller(seller),
|
||||||
Buyer = BuildBuyer(invoice),
|
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") ?? "";
|
string title = reg?.getString("InvoiceTitle") ?? "";
|
||||||
if (!string.IsNullOrWhiteSpace(title))
|
if (!string.IsNullOrWhiteSpace(title))
|
||||||
model.Notes.Add(new InvoiceNote(title));
|
model.Notes.Add(new InvoiceNote(title));
|
||||||
@@ -56,23 +55,38 @@ public static class ERechnungMapper
|
|||||||
model.VatExemptionReasons[VatCategoryCode.ReverseCharge] =
|
model.VatExemptionReasons[VatCategoryCode.ReverseCharge] =
|
||||||
new VatExemptionReason("Steuerschuldnerschaft des Leistungsempfängers (§ 13b UStG)");
|
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();
|
return EInvoice.CreateInvoice(model).Recalculate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TradeParty BuildSeller()
|
private static TradeParty BuildSeller(ERechnungSellerSettings s)
|
||||||
{
|
{
|
||||||
var seller = new TradeParty
|
var seller = new TradeParty
|
||||||
{
|
{
|
||||||
Name = SellerName,
|
Name = s.Name,
|
||||||
Address = new PostalAddress
|
Address = new PostalAddress
|
||||||
{
|
{
|
||||||
Line1 = SellerStreet,
|
Line1 = NullIfEmpty(s.Street),
|
||||||
PostalCode = SellerPostalCode,
|
PostalCode = NullIfEmpty(s.PostalCode),
|
||||||
City = SellerCity,
|
City = NullIfEmpty(s.City),
|
||||||
Country = CountryCode.Germany,
|
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;
|
return seller;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,25 +121,60 @@ public static class ERechnungMapper
|
|||||||
};
|
};
|
||||||
if (!string.IsNullOrWhiteSpace(addr.VatId))
|
if (!string.IsNullOrWhiteSpace(addr.VatId))
|
||||||
buyer.TaxRegistrations.Add(new TaxRegistration(addr.VatId.Trim(), TaxRegistrationScheme.Vat));
|
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;
|
return buyer;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PaymentInstructions BuildPayment(FdsInvoiceData invoice)
|
private static PaymentInstructions BuildPayment(FdsInvoiceData invoice, ERechnungSellerSettings s)
|
||||||
{
|
{
|
||||||
var payment = new PaymentInstructions
|
var payment = new PaymentInstructions
|
||||||
{
|
{
|
||||||
MeansCode = PaymentMeansCode.SepaCreditTransfer,
|
MeansCode = PaymentMeansCode.SepaCreditTransfer,
|
||||||
RemittanceInformation = invoice.InvoiceRegistration?.getString("InvoiceId") is { Length: > 0 } inv ? inv : null,
|
RemittanceInformation = invoice.InvoiceRegistration?.getString("InvoiceId") is { Length: > 0 } inv ? inv : null,
|
||||||
};
|
};
|
||||||
payment.CreditTransfers.Add(new CreditTransferAccount
|
if (!string.IsNullOrWhiteSpace(s.Iban))
|
||||||
{
|
payment.CreditTransfers.Add(new CreditTransferAccount
|
||||||
AccountId = SellerIban,
|
{
|
||||||
AccountName = SellerName,
|
AccountId = s.Iban.Replace(" ", ""),
|
||||||
BankId = SellerBic,
|
AccountName = NullIfEmpty(s.Name),
|
||||||
});
|
BankId = NullIfEmpty(s.Bic),
|
||||||
|
});
|
||||||
return payment;
|
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)
|
private static void AddLines(Invoice model, FdsInvoiceData invoice, bool reverseCharge)
|
||||||
{
|
{
|
||||||
int id = 0;
|
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 ────────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
private static DateOnly IssueDate(GenericObjectDictionary? reg)
|
private static DateOnly IssueDate(GenericObjectDictionary? reg)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,26 +21,30 @@ public interface IERechnungService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the hybrid PDF from the raw (non-PDF/A) visual PDF, or returns <see langword="null"/>
|
/// 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
|
/// 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>
|
/// </summary>
|
||||||
byte[]? TryBuildHybridPdf(FdsInvoiceData invoice, byte[] rawVisualPdf);
|
Task<byte[]?> TryBuildHybridPdfAsync(FdsInvoiceData invoice, byte[] rawVisualPdf);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc cref="IERechnungService"/>
|
/// <inheritdoc cref="IERechnungService"/>
|
||||||
public sealed class ERechnungService : IERechnungService
|
public sealed class ERechnungService : IERechnungService
|
||||||
{
|
{
|
||||||
private readonly ERechnungSettings _settings;
|
private readonly ERechnungSettings _settings;
|
||||||
|
private readonly IERechnungValidator _validator;
|
||||||
private readonly ILogger<ERechnungService> _logger;
|
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;
|
_settings = settings.Value;
|
||||||
|
_validator = validator;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Enabled => _settings.Enabled;
|
public bool Enabled => _settings.Enabled;
|
||||||
|
|
||||||
public byte[]? TryBuildHybridPdf(FdsInvoiceData invoice, byte[] rawVisualPdf)
|
public async Task<byte[]?> TryBuildHybridPdfAsync(FdsInvoiceData invoice, byte[] rawVisualPdf)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(invoice);
|
ArgumentNullException.ThrowIfNull(invoice);
|
||||||
if (!_settings.Enabled) return null;
|
if (!_settings.Enabled) return null;
|
||||||
@@ -51,8 +55,12 @@ public sealed class ERechnungService : IERechnungService
|
|||||||
act?.SetTag("fuchs.invoice.id", invoice.Id);
|
act?.SetTag("fuchs.invoice.id", invoice.Id);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var profile = ParseProfile(_settings.Profile);
|
var einvoice = ERechnungMapper.BuildEInvoice(invoice, _settings.Seller);
|
||||||
var einvoice = ERechnungMapper.BuildEInvoice(invoice);
|
// 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);
|
var result = einvoice.ToZugferd(profile, rawVisualPdf);
|
||||||
|
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
@@ -62,13 +70,39 @@ public sealed class ERechnungService : IERechnungService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!result.Validation.IsValid)
|
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);
|
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)",
|
_logger.LogInformation("eRechnung: hybrid PDF/A-3 built for invoice {Id} ({Profile}, {Bytes} bytes, {Ms} ms)",
|
||||||
invoice.Id, profile, result.Value.Length, sw.ElapsedMilliseconds);
|
invoice.Id, profile, hybrid.Length, sw.ElapsedMilliseconds);
|
||||||
return result.Value;
|
|
||||||
|
// 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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,10 +21,57 @@ public sealed class ERechnungSettings
|
|||||||
/// for full booking; MINIMUM/BASIC WL are intentionally not offered).</summary>
|
/// for full booking; MINIMUM/BASIC WL are intentionally not offered).</summary>
|
||||||
public string Profile { get; set; } = "EN16931";
|
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>
|
/// <summary>Formal-conformance validation settings (PDF/A-3 via veraPDF, ZUGFeRD rules).</summary>
|
||||||
public ERechnungValidationSettings Validation { get; set; } = new();
|
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>
|
/// <summary>
|
||||||
/// Settings for the external eRechnung validation service (veraPDF for PDF/A-3 and a
|
/// Settings for the external eRechnung validation service (veraPDF for PDF/A-3 and a
|
||||||
/// ZUGFeRD/EN 16931 validator), bound from "Fuchs:ERechnung:Validation".
|
/// 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>
|
/// <summary>Buyer VAT identifier (BT-48). Empty for a private person (B2C).</summary>
|
||||||
public string VatId { get; set; } = "";
|
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>
|
/// <summary>True when no VAT id is set — a private person / B2C recipient.</summary>
|
||||||
public bool IsPrivatePerson => string.IsNullOrWhiteSpace(VatId);
|
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>
|
/// <summary>Reads a structured address from a JSON object (tolerant of missing keys).</summary>
|
||||||
public static InvoiceRecipientAddress FromJson(JObject? o)
|
public static InvoiceRecipientAddress FromJson(JObject? o)
|
||||||
{
|
{
|
||||||
@@ -52,6 +61,7 @@ public sealed class InvoiceRecipientAddress
|
|||||||
City = S(o, "city", "ort"),
|
City = S(o, "city", "ort"),
|
||||||
CountryCode = S(o, "countryCode", "country").ToUpperInvariant() is { Length: > 0 } cc ? cc : "DE",
|
CountryCode = S(o, "countryCode", "country").ToUpperInvariant() is { Length: > 0 } cc ? cc : "DE",
|
||||||
VatId = S(o, "vatId", "ustid"),
|
VatId = S(o, "vatId", "ustid"),
|
||||||
|
LeitwegId = S(o, "leitwegId", "leitweg", "buyerReference"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +88,7 @@ public sealed class InvoiceRecipientAddress
|
|||||||
["city"] = City,
|
["city"] = City,
|
||||||
["countryCode"] = CountryCode,
|
["countryCode"] = CountryCode,
|
||||||
["vatId"] = VatId,
|
["vatId"] = VatId,
|
||||||
|
["leitwegId"] = LeitwegId,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ public class InvoiceService : IInvoiceService
|
|||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
|
public async Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
|
||||||
{
|
{
|
||||||
var doc = GenerateInvoicePdf(invoice, draft);
|
var doc = GenerateInvoicePdf(invoice, draft);
|
||||||
// Finalized invoices are emitted as a ZUGFeRD/Factur-X hybrid when eRechnung is enabled:
|
// 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.
|
// PDF/A-3 layer (ADR 0012). Drafts/previews keep the plain PDF/A. Any failure falls back.
|
||||||
if (!draft && _erechnung.Enabled)
|
if (!draft && _erechnung.Enabled)
|
||||||
{
|
{
|
||||||
var hybrid = _erechnung.TryBuildHybridPdf(invoice, _pdf.DocToPdfBytesRaw(doc));
|
var hybrid = await _erechnung.TryBuildHybridPdfAsync(invoice, _pdf.DocToPdfBytesRaw(doc));
|
||||||
if (hybrid is { Length: > 0 }) return Task.FromResult(hybrid);
|
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,
|
public async Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData invoice, bool draft,
|
||||||
|
|||||||
+16
-2
@@ -73,9 +73,23 @@
|
|||||||
"ERechnung": {
|
"ERechnung": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
"Profile": "EN16931",
|
"Profile": "EN16931",
|
||||||
|
"Seller": {
|
||||||
|
"Name": "Sebastian Fuchs GmbH & Co. KG",
|
||||||
|
"Street": "Germaniastraße 15",
|
||||||
|
"PostalCode": "40223",
|
||||||
|
"City": "Düsseldorf",
|
||||||
|
"CountryCode": "DE",
|
||||||
|
"TaxNumber": "106/5849/2962",
|
||||||
|
"VatId": "DE286366012",
|
||||||
|
"LegalRegistrationId": "",
|
||||||
|
"Iban": "DE76300501100045014800",
|
||||||
|
"Bic": "DUSSDEDDXXX",
|
||||||
|
"Email": "info@sanitaerfuchs.de",
|
||||||
|
"Phone": "0211 - 31 07 222"
|
||||||
|
},
|
||||||
"Validation": {
|
"Validation": {
|
||||||
"Enabled": false,
|
"Enabled": true,
|
||||||
"ServiceUrl": "",
|
"ServiceUrl": "https://api.processweb.de/api/eInvoice",
|
||||||
"FailOnError": false
|
"FailOnError": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1026,12 +1026,14 @@ $inv.eAddress = function (ev) {
|
|||||||
{ name: 'postalCode', label: 'PLZ', type: 'text', value: cur.postalCode || '' },
|
{ name: 'postalCode', label: 'PLZ', type: 'text', value: cur.postalCode || '' },
|
||||||
{ name: 'city', label: 'Ort', type: 'text', value: cur.city || '' },
|
{ name: 'city', label: 'Ort', type: 'text', value: cur.city || '' },
|
||||||
{ name: 'countryCode', label: 'Land', type: 'select', url: $inv.adrCountries, value: (cur.countryCode || 'DE') },
|
{ name: 'countryCode', label: 'Land', type: 'select', url: $inv.adrCountries, value: (cur.countryCode || 'DE') },
|
||||||
{ name: 'vatId', label: 'USt-IdNr. (nur bei Firmen)', type: 'text', value: cur.vatId || '' }
|
{ name: 'vatId', label: 'USt-IdNr. (nur bei Firmen)', type: 'text', value: cur.vatId || '' },
|
||||||
|
{ name: 'leitwegId', label: 'Leitweg-ID (nur Behörden / B2G)', type: 'text', value: cur.leitwegId || '' }
|
||||||
];
|
];
|
||||||
let hint = $$.dc('adr-conformity').append([
|
let hint = $$.dc('adr-conformity').append([
|
||||||
$$.dc('hd').text('Hinweis zur DATEV-/eRechnungs-Konformität'),
|
$$.dc('hd').text('Hinweis zur DATEV-/eRechnungs-Konformität'),
|
||||||
$$.dc('tx').text('Pflicht: Name, Straße, PLZ, Ort und Land. Die USt-IdNr. nur bei Firmen angeben — '
|
$$.dc('tx').text('Pflicht: Name, Straße, PLZ, Ort und Land. Die USt-IdNr. nur bei Firmen angeben — '
|
||||||
+ 'für eine Rechnung an eine Privatperson einfach leer lassen.')
|
+ 'für eine Rechnung an eine Privatperson einfach leer lassen. Eine Leitweg-ID nur bei '
|
||||||
|
+ 'Behörden (B2G) — dann wird die Rechnung automatisch als XRechnung erzeugt.')
|
||||||
]);
|
]);
|
||||||
$ocms.dlgform(flds, {
|
$ocms.dlgform(flds, {
|
||||||
title: 'Rechnungsempfänger',
|
title: 'Rechnungsempfänger',
|
||||||
@@ -1040,7 +1042,8 @@ $inv.eAddress = function (ev) {
|
|||||||
let a = {
|
let a = {
|
||||||
name: res.name || '', contact: res.contact || '', line2: res.line2 || '',
|
name: res.name || '', contact: res.contact || '', line2: res.line2 || '',
|
||||||
street: res.street || '', postalCode: res.postalCode || '', city: res.city || '',
|
street: res.street || '', postalCode: res.postalCode || '', city: res.city || '',
|
||||||
countryCode: (res.countryCode || 'DE'), vatId: ('' + (res.vatId || '')).trim()
|
countryCode: (res.countryCode || 'DE'), vatId: ('' + (res.vatId || '')).trim(),
|
||||||
|
leitwegId: ('' + (res.leitwegId || '')).trim()
|
||||||
};
|
};
|
||||||
let txt = $inv.composeAddress(a);
|
let txt = $inv.composeAddress(a);
|
||||||
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
||||||
@@ -1051,12 +1054,46 @@ $inv.eAddress = function (ev) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Leistungsdatum / -zeitraum: structured German-date entry only (no free text). A single date =
|
||||||
|
Leistungsdatum (BT-72); a from+to date = Leistungszeitraum (BG-14). The composed value is a
|
||||||
|
German dd.MM.yyyy string ("18.06.2026" or "01.06.2026 - 30.06.2026") that the PDF shows and the
|
||||||
|
backend maps to the eRechnung service date/period. */
|
||||||
|
$inv.provToIso = function (dmy) {
|
||||||
|
let m = ('' + (dmy || '')).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||||
|
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
|
||||||
|
};
|
||||||
|
$inv.eProvisionPeriod = function (ev) {
|
||||||
|
let cur = ('' + (ev.data && ev.data.t ? ev.data.t.text() : '')).trim();
|
||||||
|
let dates = cur.match(/\d{1,2}\.\d{1,2}\.\d{4}/g) || [];
|
||||||
|
let flds = [
|
||||||
|
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
|
||||||
|
{ name: 'bis', label: 'Leistungszeitraum – bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
|
||||||
|
];
|
||||||
|
let hint = $$.dc('adr-conformity').append([
|
||||||
|
$$.dc('hd').text('Leistungsdatum / -zeitraum'),
|
||||||
|
$$.dc('tx').text('Für ein einzelnes Datum nur „Leistungsdatum" ausfüllen; für einen Zeitraum '
|
||||||
|
+ 'zusätzlich das Bis-Datum. Nur strukturierte Datumsauswahl — für die E-Rechnung empfohlen.')
|
||||||
|
]);
|
||||||
|
$ocms.dlgform(flds, {
|
||||||
|
title: 'Leistungsdatum / -zeitraum',
|
||||||
|
addcontent: hint,
|
||||||
|
success: function (res) {
|
||||||
|
let v = ('' + (res.von || '')).trim(), b = ('' + (res.bis || '')).trim();
|
||||||
|
let txt = v === '' ? '' : ((b === '' || b === v) ? v : (v + ' - ' + b));
|
||||||
|
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
||||||
|
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
|
||||||
|
$inv.d.sync({ Target: 'provisionperiod', Value: txt });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
$inv.eHtml = function (ev) {
|
$inv.eHtml = function (ev) {
|
||||||
/* The recipient address is edited through the structured dialog whenever an invoice draft is
|
/* The recipient address and the service date/period are edited through structured dialogs
|
||||||
active; reminders (no invoice draft token) keep the plain free-text editor. */
|
whenever an invoice draft is active; reminders (no invoice draft token) keep the plain
|
||||||
if (ev.data && !(ev.data instanceof jQuery) && ev.data.nme === 'invoiceaddress'
|
free-text editor. */
|
||||||
&& $inv.d && typeof $inv.d.token === 'function' && $inv.d.token() !== '') {
|
if (ev.data && !(ev.data instanceof jQuery) && $inv.d && typeof $inv.d.token === 'function' && $inv.d.token() !== '') {
|
||||||
return $inv.eAddress.call(this, ev);
|
if (ev.data.nme === 'invoiceaddress') { return $inv.eAddress.call(this, ev); }
|
||||||
|
if (ev.data.nme === 'provisionperiod') { return $inv.eProvisionPeriod.call(this, ev); }
|
||||||
}
|
}
|
||||||
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
|
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
|
||||||
/* Single-line fields must stay plain text — the TinyMCE/html editor wraps the value in <p>
|
/* Single-line fields must stay plain text — the TinyMCE/html editor wraps the value in <p>
|
||||||
|
|||||||
@@ -1569,12 +1569,14 @@ $inv.eAddress = function (ev) {
|
|||||||
{ name: 'postalCode', label: 'PLZ', type: 'text', value: cur.postalCode || '' },
|
{ name: 'postalCode', label: 'PLZ', type: 'text', value: cur.postalCode || '' },
|
||||||
{ name: 'city', label: 'Ort', type: 'text', value: cur.city || '' },
|
{ name: 'city', label: 'Ort', type: 'text', value: cur.city || '' },
|
||||||
{ name: 'countryCode', label: 'Land', type: 'select', url: $inv.adrCountries, value: (cur.countryCode || 'DE') },
|
{ name: 'countryCode', label: 'Land', type: 'select', url: $inv.adrCountries, value: (cur.countryCode || 'DE') },
|
||||||
{ name: 'vatId', label: 'USt-IdNr. (nur bei Firmen)', type: 'text', value: cur.vatId || '' }
|
{ name: 'vatId', label: 'USt-IdNr. (nur bei Firmen)', type: 'text', value: cur.vatId || '' },
|
||||||
|
{ name: 'leitwegId', label: 'Leitweg-ID (nur Behörden / B2G)', type: 'text', value: cur.leitwegId || '' }
|
||||||
];
|
];
|
||||||
let hint = $$.dc('adr-conformity').append([
|
let hint = $$.dc('adr-conformity').append([
|
||||||
$$.dc('hd').text('Hinweis zur DATEV-/eRechnungs-Konformität'),
|
$$.dc('hd').text('Hinweis zur DATEV-/eRechnungs-Konformität'),
|
||||||
$$.dc('tx').text('Pflicht: Name, Straße, PLZ, Ort und Land. Die USt-IdNr. nur bei Firmen angeben — '
|
$$.dc('tx').text('Pflicht: Name, Straße, PLZ, Ort und Land. Die USt-IdNr. nur bei Firmen angeben — '
|
||||||
+ 'für eine Rechnung an eine Privatperson einfach leer lassen.')
|
+ 'für eine Rechnung an eine Privatperson einfach leer lassen. Eine Leitweg-ID nur bei '
|
||||||
|
+ 'Behörden (B2G) — dann wird die Rechnung automatisch als XRechnung erzeugt.')
|
||||||
]);
|
]);
|
||||||
$ocms.dlgform(flds, {
|
$ocms.dlgform(flds, {
|
||||||
title: 'Rechnungsempfänger',
|
title: 'Rechnungsempfänger',
|
||||||
@@ -1583,7 +1585,8 @@ $inv.eAddress = function (ev) {
|
|||||||
let a = {
|
let a = {
|
||||||
name: res.name || '', contact: res.contact || '', line2: res.line2 || '',
|
name: res.name || '', contact: res.contact || '', line2: res.line2 || '',
|
||||||
street: res.street || '', postalCode: res.postalCode || '', city: res.city || '',
|
street: res.street || '', postalCode: res.postalCode || '', city: res.city || '',
|
||||||
countryCode: (res.countryCode || 'DE'), vatId: ('' + (res.vatId || '')).trim()
|
countryCode: (res.countryCode || 'DE'), vatId: ('' + (res.vatId || '')).trim(),
|
||||||
|
leitwegId: ('' + (res.leitwegId || '')).trim()
|
||||||
};
|
};
|
||||||
let txt = $inv.composeAddress(a);
|
let txt = $inv.composeAddress(a);
|
||||||
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
||||||
@@ -1594,12 +1597,46 @@ $inv.eAddress = function (ev) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Leistungsdatum / -zeitraum: structured German-date entry only (no free text). A single date =
|
||||||
|
Leistungsdatum (BT-72); a from+to date = Leistungszeitraum (BG-14). The composed value is a
|
||||||
|
German dd.MM.yyyy string ("18.06.2026" or "01.06.2026 - 30.06.2026") that the PDF shows and the
|
||||||
|
backend maps to the eRechnung service date/period. */
|
||||||
|
$inv.provToIso = function (dmy) {
|
||||||
|
let m = ('' + (dmy || '')).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||||
|
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
|
||||||
|
};
|
||||||
|
$inv.eProvisionPeriod = function (ev) {
|
||||||
|
let cur = ('' + (ev.data && ev.data.t ? ev.data.t.text() : '')).trim();
|
||||||
|
let dates = cur.match(/\d{1,2}\.\d{1,2}\.\d{4}/g) || [];
|
||||||
|
let flds = [
|
||||||
|
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
|
||||||
|
{ name: 'bis', label: 'Leistungszeitraum – bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
|
||||||
|
];
|
||||||
|
let hint = $$.dc('adr-conformity').append([
|
||||||
|
$$.dc('hd').text('Leistungsdatum / -zeitraum'),
|
||||||
|
$$.dc('tx').text('Für ein einzelnes Datum nur „Leistungsdatum" ausfüllen; für einen Zeitraum '
|
||||||
|
+ 'zusätzlich das Bis-Datum. Nur strukturierte Datumsauswahl — für die E-Rechnung empfohlen.')
|
||||||
|
]);
|
||||||
|
$ocms.dlgform(flds, {
|
||||||
|
title: 'Leistungsdatum / -zeitraum',
|
||||||
|
addcontent: hint,
|
||||||
|
success: function (res) {
|
||||||
|
let v = ('' + (res.von || '')).trim(), b = ('' + (res.bis || '')).trim();
|
||||||
|
let txt = v === '' ? '' : ((b === '' || b === v) ? v : (v + ' - ' + b));
|
||||||
|
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
||||||
|
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
|
||||||
|
$inv.d.sync({ Target: 'provisionperiod', Value: txt });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
$inv.eHtml = function (ev) {
|
$inv.eHtml = function (ev) {
|
||||||
/* The recipient address is edited through the structured dialog whenever an invoice draft is
|
/* The recipient address and the service date/period are edited through structured dialogs
|
||||||
active; reminders (no invoice draft token) keep the plain free-text editor. */
|
whenever an invoice draft is active; reminders (no invoice draft token) keep the plain
|
||||||
if (ev.data && !(ev.data instanceof jQuery) && ev.data.nme === 'invoiceaddress'
|
free-text editor. */
|
||||||
&& $inv.d && typeof $inv.d.token === 'function' && $inv.d.token() !== '') {
|
if (ev.data && !(ev.data instanceof jQuery) && $inv.d && typeof $inv.d.token === 'function' && $inv.d.token() !== '') {
|
||||||
return $inv.eAddress.call(this, ev);
|
if (ev.data.nme === 'invoiceaddress') { return $inv.eAddress.call(this, ev); }
|
||||||
|
if (ev.data.nme === 'provisionperiod') { return $inv.eProvisionPeriod.call(this, ev); }
|
||||||
}
|
}
|
||||||
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
|
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
|
||||||
/* Single-line fields must stay plain text — the TinyMCE/html editor wraps the value in <p>
|
/* Single-line fields must stay plain text — the TinyMCE/html editor wraps the value in <p>
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -1550,12 +1550,14 @@ $inv.eAddress = function (ev) {
|
|||||||
{ name: 'postalCode', label: 'PLZ', type: 'text', value: cur.postalCode || '' },
|
{ name: 'postalCode', label: 'PLZ', type: 'text', value: cur.postalCode || '' },
|
||||||
{ name: 'city', label: 'Ort', type: 'text', value: cur.city || '' },
|
{ name: 'city', label: 'Ort', type: 'text', value: cur.city || '' },
|
||||||
{ name: 'countryCode', label: 'Land', type: 'select', url: $inv.adrCountries, value: (cur.countryCode || 'DE') },
|
{ name: 'countryCode', label: 'Land', type: 'select', url: $inv.adrCountries, value: (cur.countryCode || 'DE') },
|
||||||
{ name: 'vatId', label: 'USt-IdNr. (nur bei Firmen)', type: 'text', value: cur.vatId || '' }
|
{ name: 'vatId', label: 'USt-IdNr. (nur bei Firmen)', type: 'text', value: cur.vatId || '' },
|
||||||
|
{ name: 'leitwegId', label: 'Leitweg-ID (nur Behörden / B2G)', type: 'text', value: cur.leitwegId || '' }
|
||||||
];
|
];
|
||||||
let hint = $$.dc('adr-conformity').append([
|
let hint = $$.dc('adr-conformity').append([
|
||||||
$$.dc('hd').text('Hinweis zur DATEV-/eRechnungs-Konformität'),
|
$$.dc('hd').text('Hinweis zur DATEV-/eRechnungs-Konformität'),
|
||||||
$$.dc('tx').text('Pflicht: Name, Straße, PLZ, Ort und Land. Die USt-IdNr. nur bei Firmen angeben — '
|
$$.dc('tx').text('Pflicht: Name, Straße, PLZ, Ort und Land. Die USt-IdNr. nur bei Firmen angeben — '
|
||||||
+ 'für eine Rechnung an eine Privatperson einfach leer lassen.')
|
+ 'für eine Rechnung an eine Privatperson einfach leer lassen. Eine Leitweg-ID nur bei '
|
||||||
|
+ 'Behörden (B2G) — dann wird die Rechnung automatisch als XRechnung erzeugt.')
|
||||||
]);
|
]);
|
||||||
$ocms.dlgform(flds, {
|
$ocms.dlgform(flds, {
|
||||||
title: 'Rechnungsempfänger',
|
title: 'Rechnungsempfänger',
|
||||||
@@ -1564,7 +1566,8 @@ $inv.eAddress = function (ev) {
|
|||||||
let a = {
|
let a = {
|
||||||
name: res.name || '', contact: res.contact || '', line2: res.line2 || '',
|
name: res.name || '', contact: res.contact || '', line2: res.line2 || '',
|
||||||
street: res.street || '', postalCode: res.postalCode || '', city: res.city || '',
|
street: res.street || '', postalCode: res.postalCode || '', city: res.city || '',
|
||||||
countryCode: (res.countryCode || 'DE'), vatId: ('' + (res.vatId || '')).trim()
|
countryCode: (res.countryCode || 'DE'), vatId: ('' + (res.vatId || '')).trim(),
|
||||||
|
leitwegId: ('' + (res.leitwegId || '')).trim()
|
||||||
};
|
};
|
||||||
let txt = $inv.composeAddress(a);
|
let txt = $inv.composeAddress(a);
|
||||||
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
||||||
@@ -1575,12 +1578,46 @@ $inv.eAddress = function (ev) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Leistungsdatum / -zeitraum: structured German-date entry only (no free text). A single date =
|
||||||
|
Leistungsdatum (BT-72); a from+to date = Leistungszeitraum (BG-14). The composed value is a
|
||||||
|
German dd.MM.yyyy string ("18.06.2026" or "01.06.2026 - 30.06.2026") that the PDF shows and the
|
||||||
|
backend maps to the eRechnung service date/period. */
|
||||||
|
$inv.provToIso = function (dmy) {
|
||||||
|
let m = ('' + (dmy || '')).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||||
|
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
|
||||||
|
};
|
||||||
|
$inv.eProvisionPeriod = function (ev) {
|
||||||
|
let cur = ('' + (ev.data && ev.data.t ? ev.data.t.text() : '')).trim();
|
||||||
|
let dates = cur.match(/\d{1,2}\.\d{1,2}\.\d{4}/g) || [];
|
||||||
|
let flds = [
|
||||||
|
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
|
||||||
|
{ name: 'bis', label: 'Leistungszeitraum – bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
|
||||||
|
];
|
||||||
|
let hint = $$.dc('adr-conformity').append([
|
||||||
|
$$.dc('hd').text('Leistungsdatum / -zeitraum'),
|
||||||
|
$$.dc('tx').text('Für ein einzelnes Datum nur „Leistungsdatum" ausfüllen; für einen Zeitraum '
|
||||||
|
+ 'zusätzlich das Bis-Datum. Nur strukturierte Datumsauswahl — für die E-Rechnung empfohlen.')
|
||||||
|
]);
|
||||||
|
$ocms.dlgform(flds, {
|
||||||
|
title: 'Leistungsdatum / -zeitraum',
|
||||||
|
addcontent: hint,
|
||||||
|
success: function (res) {
|
||||||
|
let v = ('' + (res.von || '')).trim(), b = ('' + (res.bis || '')).trim();
|
||||||
|
let txt = v === '' ? '' : ((b === '' || b === v) ? v : (v + ' - ' + b));
|
||||||
|
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
|
||||||
|
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
|
||||||
|
$inv.d.sync({ Target: 'provisionperiod', Value: txt });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
$inv.eHtml = function (ev) {
|
$inv.eHtml = function (ev) {
|
||||||
/* The recipient address is edited through the structured dialog whenever an invoice draft is
|
/* The recipient address and the service date/period are edited through structured dialogs
|
||||||
active; reminders (no invoice draft token) keep the plain free-text editor. */
|
whenever an invoice draft is active; reminders (no invoice draft token) keep the plain
|
||||||
if (ev.data && !(ev.data instanceof jQuery) && ev.data.nme === 'invoiceaddress'
|
free-text editor. */
|
||||||
&& $inv.d && typeof $inv.d.token === 'function' && $inv.d.token() !== '') {
|
if (ev.data && !(ev.data instanceof jQuery) && $inv.d && typeof $inv.d.token === 'function' && $inv.d.token() !== '') {
|
||||||
return $inv.eAddress.call(this, ev);
|
if (ev.data.nme === 'invoiceaddress') { return $inv.eAddress.call(this, ev); }
|
||||||
|
if (ev.data.nme === 'provisionperiod') { return $inv.eProvisionPeriod.call(this, ev); }
|
||||||
}
|
}
|
||||||
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
|
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
|
||||||
/* Single-line fields must stay plain text — the TinyMCE/html editor wraps the value in <p>
|
/* Single-line fields must stay plain text — the TinyMCE/html editor wraps the value in <p>
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
Submodule eRechnungLib updated: 14f73a527a...98f45a9600
Reference in New Issue
Block a user