Compare commits

..
8 Commits
Author SHA1 Message Date
Stefan 6c38ee3d55 Refactor code structure for improved readability and maintainability
Playwright Tests / test (push) Has been cancelled
2026-07-21 09:45:38 +02:00
Stefan 4b28672d71 Update release notes and presentation for E-Rechnung enhancements 2026-07-21 09:45:17 +02:00
StefanandClaude Sonnet 5 7a9eb94a13 Track main branch for all submodules in .gitmodules
Playwright Tests / test (push) Has been cancelled
Sets submodule.<name>.branch = main so `git submodule update --remote`
resolves each submodule to the tip of main without needing a
per-invocation --branch argument.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 09:20:10 +02:00
StefanandClaude Sonnet 5 4bb6cce9f3 Update submodules to latest origin/main
Playwright Tests / test (push) Has been cancelled
Fast-forward all five submodules (OCORE, OCORE_web, OCORE_web_pdf,
OCORE_Charting, eRechnungLib) to the tip of their respective main
branches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 09:16:34 +02:00
Stefan 235c9587f0 Merge pull request 'Emit invoices as validated ZUGFeRD (DATEV) and XRechnung (B2G)' (#2) from feature/erechnung-validation-b2g into main
Playwright Tests / test (push) Has been cancelled
Reviewed-on: http://localhost:3000/Stefan/Fuchs_Intranet/pulls/2
2026-07-21 09:05:58 +02:00
StefanandClaude Opus 4.8 00e72c96d4 Emit invoices as validated ZUGFeRD (DATEV) and XRechnung (B2G)
Playwright Tests / test (pull_request) Has been cancelled
Completes the eRechnung output path: finalized invoices are emitted as a
ZUGFeRD/Factur-X EN 16931 hybrid (DATEV) or, when a Leitweg-ID is present, as
XRechnung 3.0 for B2G. Both are externally validated as ACCEPTED (0 errors) and
PDF/A-3B COMPLIANT against the ProcessWeb eInvoice service.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:05:24 +02:00
Stefan d94974ce06 Update eRechnungLib subproject to latest commit
Playwright Tests / test (push) Has been cancelled
2026-07-18 18:01:57 +02:00
Stefan 628802db19 Add function to retrieve company address as JSON and update invoice procedures
- Created a new function `fds__getCompanyAddressJson` to return a company's postal address as a structured JSON object.
- Modified stored procedures `fds__createInvoice`, `fds__setInvoice`, and `fds__prepInvoice` to include a new parameter `@SendToAddressJson` for handling the address data.
- Updated the invoice table and user-defined types to accommodate the new `SendToAddressJson` field.
- Ensured that the address data is properly retrieved and stored in the invoice records.
2026-07-18 18:01:24 +02:00
45 changed files with 2517 additions and 22 deletions
+5
View File
@@ -1,15 +1,20 @@
[submodule "OCORE"]
path = OCORE
url = https://git.processweb.de/Stefan/OCORE.git
branch = main
[submodule "OCORE_web"]
path = OCORE_web
url = https://git.processweb.de/Stefan/OCORE_web.git
branch = main
[submodule "OCORE_web_pdf"]
path = OCORE_web_pdf
url = https://git.processweb.de/Stefan/OCORE_web_pdf.git
branch = main
[submodule "OCORE_Charting"]
path = OCORE_Charting
url = https://git.processweb.de/Stefan/OCORE_Charting.git
branch = main
[submodule "eRechnungLib"]
path = eRechnungLib
url = https://git.processweb.de/ProcessWeb_Tools/eRechnungLib.git
branch = main
+192
View File
@@ -0,0 +1,192 @@
using System.Collections.Generic;
using System.Text;
using eRechnungLib.Model.CodeLists;
using eRechnungLib.Profiles;
using Fuchs.intranet;
using Fuchs.Services;
using Newtonsoft.Json.Linq;
using Xunit;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.Tests;
/// <summary>
/// Verifies the FdsInvoiceData → EN 16931 model mapping and the ZUGFeRD (EN 16931) hybrid
/// production: structured buyer, lines/totals, effortless B2C, and §13b reverse charge. See
/// ADR 0012.
/// </summary>
public class ERechnungMapperTests
{
private static FdsInvoiceData BuildInvoice(string sendToAddressJson, string vat = "19",
string invoiceOptions = "", bool withItem = true, string provisionPeriod = "")
{
var items = withItem
? "[{'id':'900','type':'material','title':'Reparatur','desc':'Vor Ort','qty':2,'price_net':50,'total_net':100,'vat':'" + vat + "'}]"
: "[]";
var jobj = JObject.Parse("{'req':[{'Id':'1','text':'Auftrag','items':" + items + "}]}");
var inv = new FdsInvoiceData(jobj)
{
InvoiceRegistration = new GenericObjectDictionary(new Dictionary<string, object>
{
["Id"] = "42",
["InvoiceId"] = "R2026-0007",
["InvoiceTitle"] = "Rechnung",
["DateCreated"] = "2026-07-17 10:00:00",
["InvoiceOptions"] = invoiceOptions,
["InvoiceBalance_net"] = "100",
["InvoiceVAT_1"] = vat,
["ProvisionPeriod"] = provisionPeriod,
["SendToAddressJson"] = sendToAddressJson,
})
};
return inv;
}
private const string B2BAddress =
"{'name':'Muster GmbH','street':'Hauptstr. 1','postalCode':'40223','city':'Düsseldorf','countryCode':'DE','vatId':'DE123456789'}";
[Fact]
public void BuildEInvoice_MapsSellerBuyerAndLines_WithConsistentTotals()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress)).Model;
Assert.Equal("Sebastian Fuchs GmbH & Co. KG", model.Seller.Name);
Assert.Equal("DE", model.Seller.Address.Country.Value);
Assert.Equal("Muster GmbH", model.Buyer.Name);
Assert.Equal("40223", model.Buyer.Address.PostalCode);
Assert.Equal("DE123456789", model.Buyer.VatId);
Assert.Equal("R2026-0007", model.InvoiceNumber);
var line = Assert.Single(model.Lines);
Assert.Equal(100m, line.NetAmount);
Assert.Equal(VatCategoryCode.StandardRate, line.VatCategory);
Assert.NotNull(model.Totals);
Assert.Equal(100m, model.Totals!.TaxExclusiveAmount);
Assert.Equal(19m, model.Totals.TaxTotalAmount);
Assert.Equal(119m, model.Totals.TaxInclusiveAmount);
}
[Fact]
public void ToZugferd_EN16931_ProducesHybridPdfWithEmbeddedCii()
{
var einvoice = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress));
var result = einvoice.ToZugferd(ZugferdProfile.EN16931);
Assert.True(result.Success);
Assert.StartsWith("%PDF", Encoding.ASCII.GetString(result.Value!, 0, 4));
// The CII XML is embedded and carries the invoice number.
string content = Encoding.Latin1.GetString(result.Value!);
Assert.Contains("CrossIndustryInvoice", content);
// No PDFA-ICC warning: the bundled sRGB profile is present, so the output intent is set.
Assert.DoesNotContain(result.Validation.Warnings, m => m.RuleId == "PDFA-ICC");
}
[Fact]
public void PrivatePerson_NoVatId_MapsWithoutBuyerTaxRegistration_AndProducesHybrid()
{
var b2c = "{'name':'Max Mustermann','street':'Weg 2','postalCode':'50667','city':'Köln','countryCode':'DE'}";
var einvoice = ERechnungMapper.BuildEInvoice(BuildInvoice(b2c));
Assert.Null(einvoice.Model.Buyer.VatId);
var result = einvoice.ToZugferd(ZugferdProfile.EN16931);
Assert.True(result.Success);
Assert.StartsWith("%PDF", Encoding.ASCII.GetString(result.Value!, 0, 4));
}
[Fact]
public void ReverseCharge_13b_SetsCategoryAeAndExemptionReason()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, vat: "0", invoiceOptions: "§13b")).Model;
var line = Assert.Single(model.Lines);
Assert.Equal(VatCategoryCode.ReverseCharge, line.VatCategory);
Assert.Equal(0m, line.VatRate);
Assert.True(model.VatExemptionReasons.ContainsKey(VatCategoryCode.ReverseCharge));
Assert.Equal(0m, model.Totals!.TaxTotalAmount);
}
[Fact]
public void LumpSumInvoice_NoItems_SynthesisesSingleLineFromTotal()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, withItem: false)).Model;
var line = Assert.Single(model.Lines);
Assert.Equal(100m, line.NetAmount);
Assert.Equal(119m, model.Totals!.TaxInclusiveAmount);
}
[Theory]
[InlineData("DE", "DE")]
[InlineData("Deutschland", "DE")]
[InlineData("Österreich", "AT")]
[InlineData("", "DE")]
public void NormalizeCountry_MapsNamesAndCodes(string raw, string expected)
=> Assert.Equal(expected, ERechnungMapper.NormalizeCountry(raw).Value);
[Fact]
public void ServicePeriod_SingleDate_MapsToDeliveryDate()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, provisionPeriod: "18.06.2026")).Model;
Assert.NotNull(model.Delivery);
Assert.Equal(new System.DateOnly(2026, 6, 18), model.Delivery!.DeliveryDate);
Assert.Null(model.InvoicingPeriod);
}
[Fact]
public void ServicePeriod_DateRange_MapsToInvoicingPeriod()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, provisionPeriod: "01.06.2026 - 30.06.2026")).Model;
Assert.NotNull(model.InvoicingPeriod);
Assert.Equal(new System.DateOnly(2026, 6, 1), model.InvoicingPeriod!.StartDate);
Assert.Equal(new System.DateOnly(2026, 6, 30), model.InvoicingPeriod.EndDate);
}
[Fact]
public void Seller_ComesFromSettings_WhenProvided()
{
var seller = new ERechnungSellerSettings { Name = "Test Handwerk GmbH", VatId = "DE999999999", Iban = "DE00" };
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress), seller).Model;
Assert.Equal("Test Handwerk GmbH", model.Seller.Name);
Assert.Equal("DE999999999", model.Seller.VatId);
}
[Fact]
public void B2G_WithLeitwegId_SetsBuyerReference_AndSellerContact()
{
var b2g = "{'name':'Stadt Düsseldorf','street':'Marktplatz 2','postalCode':'40213','city':'Düsseldorf','countryCode':'DE','leitwegId':'05111-12345-67'}";
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(b2g)).Model;
Assert.Equal("05111-12345-67", model.BuyerReference); // BT-10 (Leitweg-ID)
Assert.NotNull(model.Seller.ElectronicAddress); // BT-34 (XRechnung)
Assert.NotNull(model.Seller.Contact); // BG-6 (BR-DE-5/6/7)
Assert.Equal("info@sanitaerfuchs.de", model.Seller.Contact!.Email);
}
[Fact]
public void B2B_NoLeitwegId_LeavesBuyerReferenceUnset()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress)).Model;
Assert.True(string.IsNullOrEmpty(model.BuyerReference));
}
[Fact]
public void Seller_HasVatIdAndPaymentTerms_ForBrCo25AndBrCo26()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress)).Model;
// BR-CO-26: seller VAT id (BT-31) present, in addition to the Steuernummer (BT-32).
Assert.Equal("DE286366012", model.Seller.VatId);
// BR-CO-25: payment terms (BT-20) / due date (BT-9) present for a positive amount due.
Assert.NotNull(model.PaymentTerms);
Assert.False(string.IsNullOrWhiteSpace(model.PaymentTerms!.Description));
}
[Fact]
public void B2G_ToZugferdXRechnung_ProducesHybridWithXRechnungCustomization()
{
var b2g = "{'name':'Stadt Düsseldorf','street':'Marktplatz 2','postalCode':'40213','city':'Düsseldorf','countryCode':'DE','leitwegId':'05111-12345-67'}";
var result = ERechnungMapper.BuildEInvoice(BuildInvoice(b2g)).ToZugferd(ZugferdProfile.XRechnung);
Assert.True(result.Success);
string content = System.Text.Encoding.Latin1.GetString(result.Value!);
Assert.Contains("xrechnung_3.0", content); // XRechnung 3.0 CIUS customization id
}
}
+114
View File
@@ -0,0 +1,114 @@
using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Exercises the external eRechnung validator client against a stubbed HTTP endpoint: response
/// parsing, the not-configured short-circuit, and unreachable/error handling (never throws).
/// </summary>
public class ERechnungValidatorTests
{
private sealed class StubHandler : HttpMessageHandler
{
private readonly HttpStatusCode _status;
private readonly string _body;
public HttpRequestMessage? Last;
public StubHandler(HttpStatusCode status, string body) { _status = status; _body = body; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
Last = request;
return Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
}
}
private sealed class StubFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public StubFactory(HttpMessageHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
}
private static ProcessWebERechnungValidator Make(HttpMessageHandler handler, bool enabled = true,
string url = "https://validator.test/api/eInvoice")
{
var settings = Options.Create(new ERechnungSettings
{
Validation = new ERechnungValidationSettings { Enabled = enabled, ServiceUrl = url }
});
return new ProcessWebERechnungValidator(new StubFactory(handler), settings,
NullLogger<ProcessWebERechnungValidator>.Instance);
}
[Fact]
public async Task ValidatePdf_BothPass_ReturnsIsValid()
{
var handler = new StubHandler(HttpStatusCode.OK,
"{\"isValid\":true,\"summary\":\"ok\",\"xml\":{\"isValid\":true},\"pdfa\":{\"isCompliant\":true}}");
var result = await Make(handler).ValidatePdfAsync(new byte[] { 1, 2, 3 });
Assert.True(result.IsValid);
Assert.True(result.XmlValid);
Assert.True(result.PdfACompliant);
Assert.EndsWith("/validatepdf", handler.Last!.RequestUri!.ToString());
Assert.Equal("application/pdf", handler.Last.Content!.Headers.ContentType!.MediaType);
}
[Theory]
[InlineData("{\"xml\":{\"isValid\":false,\"errorCount\":3},\"pdfa\":{\"isCompliant\":true}}", false, true)]
[InlineData("{\"xml\":{\"isValid\":true},\"pdfa\":{\"isCompliant\":false}}", true, false)]
public async Task ValidatePdf_PartialFailure_IsNotValid(string body, bool xml, bool pdfa)
{
var result = await Make(new StubHandler(HttpStatusCode.OK, body)).ValidatePdfAsync(new byte[] { 1 });
Assert.False(result.IsValid);
Assert.Equal(xml, result.XmlValid);
Assert.Equal(pdfa, result.PdfACompliant);
Assert.True(result.HasHardError); // real PDF/A or XML errors are hard failures
}
[Fact]
public async Task ValidatePdf_PdfAOk_ButXmlScenarioNotMatched_IsNotHardError()
{
// EN 16931 ZUGFeRD checked by an XRechnung-only scenario set: PDF/A compliant, XML rejected
// with zero errors → not strictly valid, but not a hard error (must not withhold the invoice).
var body = "{\"xml\":{\"isValid\":false,\"scenarioMatched\":false,\"errorCount\":0},\"pdfa\":{\"isCompliant\":true}}";
var result = await Make(new StubHandler(HttpStatusCode.OK, body)).ValidatePdfAsync(new byte[] { 1 });
Assert.True(result.PdfACompliant);
Assert.False(result.ScenarioMatched);
Assert.False(result.IsValid);
Assert.False(result.HasHardError);
}
[Fact]
public async Task ValidatePdf_Disabled_ReturnsNotConfigured()
{
var result = await Make(new StubHandler(HttpStatusCode.OK, "{}"), enabled: false).ValidatePdfAsync(new byte[] { 1 });
Assert.False(result.Configured);
Assert.False(result.IsValid);
}
[Fact]
public async Task ValidatePdf_ServerError_IsReachedFalse_AndDoesNotThrow()
{
var result = await Make(new StubHandler(HttpStatusCode.BadGateway, "validator down")).ValidatePdfAsync(new byte[] { 1 });
Assert.False(result.Reached);
Assert.False(result.IsValid);
}
[Fact]
public async Task ValidatePdf_Enabled_RequiresNonEmptyUrl()
{
var v = Make(new StubHandler(HttpStatusCode.OK, "{}"), url: "");
Assert.False(v.Enabled);
var result = await v.ValidatePdfAsync(new byte[] { 1 });
Assert.False(result.Configured);
}
}
+31
View File
@@ -84,6 +84,37 @@ public class InvoiceDraftServiceTests
Assert.Equal(1, h.Version);
}
[Fact]
public void ApplyPatch_StructuredAddress_ComposesFreeTextAndPersistsJson()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var addr = JObject.Parse(@"{'name':'Muster GmbH','street':'Weg 1','postalCode':'40223','city':'Düsseldorf','countryCode':'DE','vatId':'DE123456789'}");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "address", Value = addr });
Assert.NotNull(s2);
// Free-text block is composed for the PDF / SendToAddress path.
Assert.Equal("Muster GmbH\nWeg 1\n40223 Düsseldorf", s2!.New["invoiceaddress"]!.Value<string>());
// Structured JSON rides inside the CustomValues blob (interim persistence).
var parsed = InvoiceRecipientAddress.FromCustomValues(s2.New["CustomValues"]!.Value<string>());
Assert.NotNull(parsed);
Assert.Equal("DE123456789", parsed!.VatId);
Assert.True(parsed.IsEn16931Conformant);
}
[Fact]
public void ApplyPatch_Address_PlainString_KeepsLegacyFreeTextBehavior()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "address", Value = JToken.FromObject("Weg 9\n50667 Köln") });
Assert.Equal("Weg 9\n50667 Köln", s2!.New["invoiceaddress"]!.Value<string>());
Assert.Null(InvoiceRecipientAddress.FromCustomValues(s2.New["CustomValues"]?.Value<string>()));
}
[Fact]
public void ApplyPatch_BlockReplace_RecomputesTotals()
{
+104
View File
@@ -0,0 +1,104 @@
using Fuchs.Services;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Pure-logic tests for the structured invoice recipient address: free-text composition for the
/// PDF, EN 16931 / DATEV conformity detection, effortless B2C (no VAT id), and JSON round-tripping
/// through the <c>CustomValues</c> blob. See ADR 0012.
/// </summary>
public class InvoiceRecipientAddressTests
{
private static InvoiceRecipientAddress FullB2B() => new()
{
Name = "Muster GmbH",
Contact = "Frau Schmidt",
Street = "Hauptstraße 1",
PostalCode = "40223",
City = "Düsseldorf",
CountryCode = "DE",
VatId = "DE123456789",
};
[Fact]
public void Compose_DomesticFullAddress_OmitsCountryLineAndVatId()
{
var text = FullB2B().Compose();
Assert.Equal("Muster GmbH\nz.Hd. Frau Schmidt\nHauptstraße 1\n40223 Düsseldorf", text);
Assert.DoesNotContain("DE123456789", text); // VAT id never in the postal block
Assert.DoesNotContain("\nDE", text); // domestic country code suppressed
}
[Fact]
public void Compose_ForeignCountry_AppendsCountryCodeLine()
{
var addr = FullB2B();
addr.CountryCode = "AT";
addr.City = "Wien";
addr.PostalCode = "1010";
Assert.EndsWith("1010 Wien\nAT", addr.Compose());
}
[Fact]
public void PrivatePerson_NoVatId_IsB2CAndStillEn16931Conformant()
{
var addr = new InvoiceRecipientAddress
{
Name = "Max Mustermann",
Street = "Weg 2",
PostalCode = "50667",
City = "Köln",
CountryCode = "DE",
// no VatId
};
Assert.True(addr.IsPrivatePerson);
Assert.True(addr.IsEn16931Conformant);
Assert.Empty(addr.MissingForEn16931());
}
[Theory]
[InlineData("", "DE", "Köln", new[] { "Name" })]
[InlineData("Firma", "", "Köln", new[] { "Land" })]
[InlineData("Firma", "DE", "", new[] { "Ort/PLZ" })]
public void MissingForEn16931_FlagsMandatoryGaps(string name, string country, string city, string[] expected)
{
var addr = new InvoiceRecipientAddress { Name = name, CountryCode = country, City = city };
Assert.Equal(expected, addr.MissingForEn16931());
Assert.False(addr.IsEn16931Conformant);
}
[Fact]
public void FromJson_AcceptsAlternateKeys()
{
var addr = InvoiceRecipientAddress.FromJson(JObject.Parse(
@"{'name':'X','plz':'12345','ort':'Ort','country':'de','ustid':'DE9'}"));
Assert.Equal("12345", addr.PostalCode);
Assert.Equal("Ort", addr.City);
Assert.Equal("DE", addr.CountryCode); // uppercased
Assert.Equal("DE9", addr.VatId);
}
[Fact]
public void FromCustomValues_RoundTripsThroughTheBlob()
{
var cv = new JObject
{
["contactName"] = "someone",
[InvoiceRecipientAddress.CustomValuesKey] = FullB2B().ToJson(),
};
var parsed = InvoiceRecipientAddress.FromCustomValues(cv.ToString());
Assert.NotNull(parsed);
Assert.Equal("Muster GmbH", parsed!.Name);
Assert.Equal("DE123456789", parsed.VatId);
}
[Fact]
public void FromCustomValues_ReturnsNull_WhenNoStructuredAddressPresent()
{
Assert.Null(InvoiceRecipientAddress.FromCustomValues(@"{'contactName':'x'}"));
Assert.Null(InvoiceRecipientAddress.FromCustomValues(""));
Assert.Null(InvoiceRecipientAddress.FromCustomValues("not json"));
}
}
+6 -1
View File
@@ -168,7 +168,12 @@ OCORE_Charting (standalone — referenced by solution but no direct project ref
### 4.3 Service Layer (Dependency Injection)
Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`:
`IComService`, `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService`.
`IComService`, `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService`, `IERechnungService`.
`IERechnungService` (singleton) maps a finalized invoice to the EN 16931 model and embeds the
CII XML into the render-only visual PDF to produce a ZUGFeRD/Factur-X **PDF/A-3** hybrid via the
`eRechnungLib` submodule (gated by `Fuchs:ERechnung:Enabled`; falls back to the plain PDF/A on
disable/failure). See [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md) and ADR 0012.
Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; DB/request-scoped services are scoped (see `Program.cs`).
The **Admin** module (`Do_Process_Admin`, `ISystemStatusService`) surfaces a live system-status/diagnostics page (host, SQL/Key Vault/blob/MFR connectivity, email config, test-email) restricted to `fds_sys` > 4 — see ADR [0011](Decisions/0011-admin-module-system-status.md) and the [concept doc](Concepts/admin-system-status.md).
`FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`).
+84
View File
@@ -0,0 +1,84 @@
---
status: Active
lastUpdated: 2026-07-18
applyTo:
- "Fuchs/Services/ERechnungMapper.cs"
- "Fuchs/Services/ERechnungService.cs"
- "Fuchs/Services/ERechnungSettings.cs"
- "Fuchs/Services/InvoiceRecipientAddress.cs"
- "Fuchs/Services/InvoiceService.cs"
- "Fuchs/code/FuchsPdf.cs"
- "eRechnungLib/**"
relatedDecisions:
- "0005-pdf-generation-and-erechnung.md"
- "0012-erechnung-single-pdfa-engine-pipeline.md"
---
# eRechnung output (ZUGFeRD/Factur-X)
## Summary
Finalized invoices are emitted as a **ZUGFeRD 2.4 / Factur-X** hybrid: the FuchsPdf visual PDF
with the EN 16931 **CII XML** embedded, in a formally conformant **PDF/A-3**. This makes invoices
DATEV-ingestible and satisfies the B2B/B2G e-invoicing mandate. The library doing the structured
XML + PDF/A-3 work is the `eRechnungLib` submodule; Fuchs supplies the invoice data and the visual
PDF.
## How it works
```
Editor (structured recipient dialog)
→ InvoiceDraftEditService (address delta = JSON object, stored in CustomValues.sendToAddress)
→ fds__setInvoice/… (persisted; dedicated SendToAddressJson column + composed SendToAddress)
→ InvoiceService.RenderInvoicePdfBytesAsync(final)
├─ FuchsPdf.DocToPdfBytesRaw(doc) → visual PDF (no Spire PDF/A)
└─ IERechnungService.TryBuildHybridPdf
├─ ERechnungMapper.BuildEInvoice FdsInvoiceData → eRechnungLib.Model.Invoice
└─ EInvoice.ToZugferd(EN16931, raw) → PDF/A-3 + Factur-X hybrid (bundled sRGB ICC)
```
- **Single PDF/A engine (ADR 0012).** eRechnungLib owns the one PDF/A-3 layer. For the eRechnung
path the Spire PDF/A step is skipped (`DocToPdfBytesRaw`); Spire stays only for on-screen preview
rasterisation. This avoids a conflicting second output intent / `pdfaid` marker.
- **Structured recipient address.** `InvoiceRecipientAddress` holds the EN 16931 buyer fields
(name, street, post code, city, country BT-55, optional VAT id BT-48). It is edited via a
dialog form (`$inv.eAddress` in `fis.inv_shared.js`), prefilled from `fds__prepInvoice`'s
`invoiceaddressData`, and carried as a JSON object through the draft cache. A private person
(no VAT id) is fully valid — B2C stays effortless. The free-text `SendToAddress` is composed
from it so the PDF layout is unchanged.
- **Mapping.** `ERechnungMapper` maps the Fuchs invoice to the EN 16931 model: seller = Fuchs
(from `Fuchs:ERechnung:Seller` config — name, address, Steuernummer BT-32, **USt-IdNr BT-31**,
IBAN/BIC, contact; defaults mirror the FuchsPdf letterhead), buyer = the structured recipient,
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
validated). `IERechnungService` returns `null` on disable **or any failure**, so
`RenderInvoicePdfBytesAsync` falls back to the plain Spire PDF/A — invoicing never breaks.
- **Formal verification.** `Fuchs:ERechnung:Validation` calls the ProcessWeb eInvoice service
(`POST {ServiceUrl}/validatepdf`, raw `application/pdf`) which checks the EN 16931 XML **and**
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
- `Fuchs/Services/InvoiceRecipientAddress.cs` — structured buyer address, composition, conformity.
- `Fuchs/Services/ERechnungMapper.cs``FdsInvoiceData``eRechnungLib.Model.Invoice`.
- `Fuchs/Services/ERechnungService.cs` / `ERechnungSettings.cs` — hybrid production + config.
- `Fuchs/Services/InvoiceService.cs` — wiring in `RenderInvoicePdfBytesAsync`.
- `Fuchs/code/FuchsPdf.cs``DocToPdfBytesRaw` (render-only visual PDF).
- `Fuchs/js/intranet/modules/fis.inv_shared.js``$inv.eAddress` structured dialog.
- `Fuchs_Database``fds__invoices.SendToAddressJson`, `fds__getCompanyAddressJson`,
`fds__prepInvoice.invoiceaddressData`, `fds__createInvoice`/`setInvoice`/`getInvoice`.
- `eRechnungLib/**` — CII/UBL serialization, EN 16931 validation, `FacturXPdfBuilder` (PDF/A-3).
## Related decisions
- [`0005-pdf-generation-and-erechnung.md`](../Decisions/0005-pdf-generation-and-erechnung.md)
- [`0012-erechnung-single-pdfa-engine-pipeline.md`](../Decisions/0012-erechnung-single-pdfa-engine-pipeline.md)
@@ -0,0 +1,81 @@
---
status: Accepted
date: 2026-07-17
applyTo:
- "Fuchs/code/FuchsPdf.cs"
- "Fuchs/Services/FuchsPdfService.cs"
- "Fuchs/Services/InvoiceService.cs"
- "Fuchs/Services/ERechnungSettings.cs"
- "eRechnungLib/**"
supersededBy: ""
---
# 0012 — eRechnung uses a single PDF/A engine (eRechnungLib owns PDF/A-3)
## Context
ADR 0005 established that invoices are emitted as eRechnung by embedding the CII
XML into the FuchsPdf-rendered visual PDF via `eRechnungLib.ToZugferd(...)`.
Two hard requirements then surfaced: the emitted invoice must (a) satisfy the
**ZUGFeRD 2.4 / Factur-X** standard for **DATEV** ingestion, and (b) be a
**formally verifiable PDF/A-3** (veraPDF-clean).
A conflict became apparent in the rendering pipeline. `FuchsPdf.DocToPdfBytes`
post-processes its MigraDoc/PdfSharp output to **PDF/A via Spire**
(`OCORE…pdfAFileContent`). `eRechnungLib`'s `FacturXPdfBuilder` **also** produces
a PDF/A layer (raises to PDF 1.7, writes `pdfaid` XMP, adds an sRGB output
intent, embeds `factur-x.xml` in `/AF`). Feeding a Spire-made PDF/A into
eRechnungLib stacks **two** PDF/A conversions → duplicate/*conflicting* output
intents and `pdfaid` markers, which veraPDF rejects. Spire's output is also
PDF/A-1/2 and does **not** carry the `/AF` associated-file structure ZUGFeRD
requires (PDF/A-3).
Separately, `eRechnungLib` shipped **no** sRGB ICC profile, so its output intent
was silently omitted (`PDFA-ICC` warning) — never formally PDF/A-3 conformant.
## Decision
- **eRechnungLib is the single PDF/A engine for eRechnung output.** The invoice
visual PDF is rendered by `FuchsPdf` **without** the Spire PDF/A step and handed
to `eRechnungLib.ToZugferd(ZugferdProfile.EN16931, rawPdfBytes)`, which owns the
one PDF/A-3 conversion and embeds the CII XML. The render-only path is
`FuchsPdf.DocToPdfBytesRaw` / `IPdfService.DocToPdfBytesRaw` (fonts still
embedded via `OCOREFontResolver`, no PDF/A post-processing).
- **Spire stays only for on-screen preview rasterisation** (`DocToImageCollection`
/ `BytesToImageCollection` for `sprep`/`sedit`). It is **not** part of the
eRechnung file's PDF/A path. `DocToPdfBytes` (render + Spire PDF/A) is unchanged
and remains the path for non-eRechnung documents (e.g. reminders).
- **A bundled sRGB ICC profile is required.** `eRechnungLib` ships
`Resources/Color/sRGB.icc` (sRGB IEC61966-2.1) so the PDF/A output intent is
always attached. A caller may override it per conversion via
`ConversionOptions.IccProfile`.
- **Profile is EN 16931.** MINIMUM / BASIC WL are not offered for real invoices —
DATEV needs at least EN 16931 (COMFORT) for full booking.
- **Formal conformance is verified by an external online service** (veraPDF for
PDF/A-3 + a ZUGFeRD/EN 16931 validator), behind the configurable
`Fuchs:ERechnung:Validation:ServiceUrl` seam. Until the URL is provisioned,
verification reports "not configured / skipped".
## Consequences
- The eRechnung invoice PDF and a plain Spire PDF/A must never both be produced
for the same document — pick the render-only path when emitting eRechnung.
- The incoming visual PDF must itself be PDF/A-friendly (fonts embedded —
handled; letterhead images must be **RGB, not CMYK**; transparency is allowed
because we target PDF/A-**3**).
- `Fuchs:ERechnung:Enabled` gates emission and stays `false` until the
`FdsInvoiceData``eRechnungLib.Model.Invoice` mapping is wired (the ADR 0005
follow-up). Open item for that mapping: the buyer address is currently a
free-text block (`SendToAddress`); EN 16931 needs **structured** buyer
fields (name/postcode/city/country, VAT id), so structured customer master
data must feed the mapping. Seller data (currently hard-coded in `FuchsPdf`:
name, address, tax number, IBAN/BIC) must be lifted into the seller model.
- `Fuchs.csproj` must add a project reference to `eRechnungLib` when the flow is
wired (not present yet).
## Alternatives considered
- **Keep Spire PDF/A and have eRechnungLib only embed the XML:** rejected — Spire
produces the wrong PDF/A part (1/2, no `/AF`) and a second conversion collides
with eRechnungLib's own output intent/XMP, failing veraPDF.
- **Drop Spire entirely:** rejected — Spire is still needed to rasterise PDFs to
the on-screen invoice/reminder preview images; PdfSharp/eRechnungLib cannot.
- **Ship no ICC and rely on callers:** rejected — formal PDF/A-3 requires an
output intent; bundling a profile makes conformance the default.
+12 -2
View File
@@ -90,8 +90,14 @@ payload; see `EVAL_live_invoice_editing.md` for the rationale.
### 4.1 What the user can change
- **Line items** — quantities, prices, notes, combine into one sum
(`$inv.rendersrq`, `$inv.quantChange`).
- **Recipient fields** — invoice title, address, email, provision
location/period (inline edit fields, `fm(...)` helper in `fis.inv_shared.js`).
- **Recipient fields** — invoice title, email, provision location/period (inline
edit fields, `fm(...)` helper in `fis.inv_shared.js`). The **recipient address**
is edited via a **structured dialog** (`$inv.eAddress`: name, street, PLZ, city,
country, optional VAT id) prefilled from `fds__prepInvoice`'s `invoiceaddressData`;
it drives the EN 16931 eRechnung and composes the free-text `SendToAddress` for the
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.
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
(default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational
@@ -360,6 +366,10 @@ flowchart TD
- **Draft vs. final changes the rendered PDF**: draft = watermark overlay, no
GiroCode; final = no watermark, GiroCode payment QR added when there's a
positive balance.
- **Final invoices can be emitted as eRechnung**: when `Fuchs:ERechnung:Enabled`,
the final PDF is a ZUGFeRD/Factur-X **PDF/A-3 hybrid** (eRechnungLib embeds the
CII XML into the render-only visual PDF; any failure falls back to the plain
PDF/A). Off by default. See [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md).
- **Email is best-effort and tracked**: `IsSent` is only set `true`
automatically after a *successful* send; a failed send still leaves a
correctly finalised, stored invoice that staff can resend or mark sent
+489
View File
@@ -0,0 +1,489 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fuchs Intranet — Das ist neu</title>
<style>
:root{
--blue:#1b4379; /* $fuchs_blau */
--blue-2:#2a5da3;
--accent:#56a532; /* $fuchs_akzent */
--accent-2:#74c14a;
--ink:#12243f;
--ink-2:#1a2f52;
--paper:#f4f6fa;
--card:#ffffff;
--text:#1c2430;
--muted:#586172;
--line:#e3e8f0; /* near $fuchs_lightgray */
--green:#56a532;
--shadow:0 18px 50px -20px rgba(18,36,63,.35);
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{
margin:0;
font-family:"Segoe UI",system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif;
color:var(--text);
background:var(--paper);
line-height:1.6;
-webkit-font-smoothing:antialiased;
}
.wrap{max-width:1080px;margin:0 auto;padding:0 24px}
/* ---------- HERO ---------- */
.hero{
position:relative;
color:#fff;
background:
radial-gradient(1200px 500px at 80% -10%, rgba(86,165,50,.38), transparent 60%),
radial-gradient(900px 500px at 0% 10%, rgba(42,93,163,.50), transparent 55%),
linear-gradient(160deg,#1b3a63 0%, #12243f 60%, #0b1727 100%);
overflow:hidden;
border-bottom:1px solid rgba(255,255,255,.06);
}
.hero::after{
content:"";position:absolute;inset:0;
background:linear-gradient(180deg,transparent 60%,rgba(0,0,0,.25));
pointer-events:none;
}
.hero .wrap{position:relative;z-index:2;padding:78px 24px 92px}
.eyebrow{
display:inline-flex;align-items:center;gap:9px;
font-size:.8rem;font-weight:600;letter-spacing:.14em;text-transform:uppercase;
color:var(--accent-2);
background:rgba(86,165,50,.12);
border:1px solid rgba(86,165,50,.28);
padding:7px 15px;border-radius:100px;
}
.eyebrow .dot{width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 14px var(--accent)}
h1{
font-size:clamp(2.1rem,5vw,3.6rem);
line-height:1.08;margin:22px 0 16px;font-weight:800;letter-spacing:-.02em;
}
h1 .grad{
background:linear-gradient(92deg,var(--accent-2),#fff 70%);
-webkit-background-clip:text;background-clip:text;color:transparent;
}
.lede{font-size:clamp(1.05rem,2.2vw,1.28rem);color:#c7cdda;max-width:640px;margin:0}
.hero-meta{
display:flex;flex-wrap:wrap;gap:26px;margin-top:38px;
padding-top:26px;border-top:1px solid rgba(255,255,255,.1);
}
.hero-meta div{min-width:120px}
.hero-meta b{display:block;font-size:1.7rem;font-weight:800;color:#fff}
.hero-meta span{font-size:.86rem;color:#98a1b3}
/* ---------- SECTIONS ---------- */
section{padding:64px 0}
.section-head{max-width:680px;margin-bottom:40px}
.section-head .kicker{color:var(--accent);font-weight:700;font-size:.82rem;letter-spacing:.12em;text-transform:uppercase}
h2{font-size:clamp(1.6rem,3.4vw,2.3rem);margin:10px 0 12px;font-weight:800;letter-spacing:-.02em}
.section-head p{color:var(--muted);font-size:1.06rem;margin:0}
/* ---------- FEATURE CARDS ---------- */
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:22px}
.card{
background:var(--card);
border:1px solid var(--line);
border-radius:18px;
padding:28px 26px;
box-shadow:var(--shadow);
position:relative;
transition:transform .25s ease, box-shadow .25s ease;
overflow:hidden;
}
.card::before{
content:"";position:absolute;top:0;left:0;right:0;height:3px;
background:linear-gradient(90deg,var(--accent),var(--accent-2));
opacity:.9;
}
.card:hover{transform:translateY(-5px);box-shadow:0 26px 60px -24px rgba(20,24,33,.45)}
.card .ico{
width:48px;height:48px;border-radius:13px;display:grid;place-items:center;
background:linear-gradient(150deg,rgba(86,165,50,.16),rgba(116,193,74,.06));
border:1px solid rgba(86,165,50,.22);
font-size:1.5rem;margin-bottom:16px;
}
.card h3{margin:0 0 8px;font-size:1.18rem;font-weight:700}
.card p{margin:0;color:var(--muted);font-size:.97rem}
.card .tag{
display:inline-block;margin-top:16px;font-size:.75rem;font-weight:600;
color:var(--accent);background:rgba(86,165,50,.09);
border:1px solid rgba(86,165,50,.2);padding:4px 11px;border-radius:100px;
}
/* ---------- BEFORE / AFTER ---------- */
.compare{background:linear-gradient(180deg,#fff,#f2f4f9);border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
.table-scroll{overflow-x:auto;border-radius:16px;box-shadow:var(--shadow);border:1px solid var(--line)}
table{border-collapse:collapse;width:100%;min-width:640px;background:#fff}
th,td{text-align:left;padding:16px 20px;border-bottom:1px solid var(--line);vertical-align:top}
thead th{background:var(--ink);color:#fff;font-weight:600;font-size:.92rem;letter-spacing:.01em}
thead th:first-child{border-top-left-radius:16px}
thead th:last-child{border-top-right-radius:16px}
tbody tr:last-child td{border-bottom:none}
td.feat{font-weight:700;color:var(--ink);width:24%}
td.old{color:var(--muted)}
td.old::before{content:"✕ ";color:#c4453b;font-weight:700}
td.new{color:#1c2733}
td.new::before{content:"✓ ";color:var(--green);font-weight:700}
tbody tr:nth-child(even){background:#fafbfe}
/* ---------- SPOTLIGHT ---------- */
.spot{display:grid;grid-template-columns:1.05fr .95fr;gap:38px;align-items:center}
.spot-card{
background:linear-gradient(160deg,var(--ink),var(--ink-2));
color:#fff;border-radius:22px;padding:34px;box-shadow:var(--shadow);
border:1px solid rgba(255,255,255,.07);
}
.spot-card h3{margin:0 0 14px;font-size:1.35rem}
.spot-card ul{margin:0;padding:0;list-style:none}
.spot-card li{position:relative;padding:9px 0 9px 30px;color:#cdd3df;border-bottom:1px dashed rgba(255,255,255,.09)}
.spot-card li:last-child{border-bottom:none}
.spot-card li::before{content:"→";position:absolute;left:0;color:var(--accent-2);font-weight:800}
.spot-text h2{margin-top:0}
.spot-text p{color:var(--muted)}
.chip{display:inline-block;font-size:.78rem;font-weight:600;color:var(--accent);background:rgba(86,165,50,.1);border:1px solid rgba(86,165,50,.22);padding:5px 12px;border-radius:100px;margin-bottom:14px}
/* ---------- KEY USER BOX ---------- */
.keyuser{background:var(--ink);color:#fff}
.keyuser .section-head p{color:#a7afbe}
.ku-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:20px}
.ku{
background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.09);
border-radius:16px;padding:24px;
}
.ku h3{margin:0 0 8px;font-size:1.05rem;color:#fff}
.ku h3 span{color:var(--accent-2)}
.ku p{margin:0;color:#a7afbe;font-size:.93rem}
/* ---------- EDITOR STEPS ---------- */
.steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:18px;margin-top:8px}
.step{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:22px 20px;box-shadow:0 10px 30px -20px rgba(18,36,63,.3)}
.step .n{font-size:.78rem;font-weight:800;color:var(--accent);letter-spacing:.08em}
.step h4{margin:6px 0 6px;font-size:1.04rem}
.step p{margin:0;color:var(--muted);font-size:.93rem}
/* ---------- SET-PRICE PANEL ---------- */
.setpanel{
margin-top:34px;border:1px solid var(--line);border-radius:22px;
background:linear-gradient(160deg,#ffffff,#eef4ea);
padding:34px 32px;box-shadow:var(--shadow);position:relative;overflow:hidden;
}
.setpanel::before{content:"";position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(90deg,var(--blue),var(--accent))}
.setpanel > .ttl{display:flex;align-items:center;gap:12px;margin-bottom:6px}
.setpanel > .ttl .badge{font-size:1.4rem}
.setpanel h3{margin:0;font-size:1.35rem;font-weight:800;color:var(--blue)}
.setpanel > p{margin:8px 0 0;color:var(--muted);max-width:720px}
.setgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:20px;margin-top:26px}
.setvar{background:#fff;border:1px solid var(--line);border-radius:16px;padding:24px 22px;box-shadow:0 12px 32px -22px rgba(18,36,63,.4);display:flex;flex-direction:column}
.setvar .num{
width:36px;height:36px;border-radius:11px;display:grid;place-items:center;
background:linear-gradient(150deg,var(--blue),var(--blue-2));color:#fff;font-weight:800;
font-size:1.05rem;margin-bottom:14px;
}
.setvar h4{margin:0 0 8px;font-size:1.06rem;line-height:1.3}
.setvar h4 small{display:block;font-size:.76rem;font-weight:600;color:var(--accent);letter-spacing:.04em;margin-top:3px}
.setvar p{margin:0 0 12px;color:var(--muted);font-size:.93rem}
.setvar .kv{margin-top:auto;font-size:.82rem;color:var(--blue);font-weight:600}
.pill{display:inline-block;font-size:.72rem;font-weight:700;padding:3px 10px;border-radius:100px;margin-top:10px}
.pill.rev{color:#8a6d3b;background:rgba(210,150,40,.14);border:1px solid rgba(210,150,40,.35)}
.pill.one{color:#a03d2e;background:rgba(196,69,59,.12);border:1px solid rgba(196,69,59,.3)}
.setnote{
margin-top:24px;padding:16px 20px;border-radius:14px;
background:rgba(27,67,121,.06);border:1px solid rgba(27,67,121,.16);
color:#2a3b52;font-size:.92rem;
}
.setnote b{color:var(--blue)}
/* ---------- FOOTER ---------- */
footer{padding:44px 0;text-align:center;color:var(--muted);font-size:.9rem;border-top:1px solid var(--line)}
footer b{color:var(--ink)}
@media(max-width:760px){
.spot{grid-template-columns:1fr}
section{padding:48px 0}
}
</style>
</head>
<body>
<header class="hero">
<div class="wrap">
<span class="eyebrow"><span class="dot"></span>Release-Übersicht · 2026</span>
<h1>Ihr Intranet wird<br><span class="grad">schneller, sicherer, transparenter.</span></h1>
<p class="lede">Die neue Generation des Fuchs Intranets bringt Live-Vorschau bei der Rechnungserstellung, Echtzeit-Rückmeldungen, extern validierte E-Rechnung (ZUGFeRD/DATEV und XRechnung) und eine durchgängig geprüfte Datenverarbeitung — ohne dass sich Ihr gewohnter Arbeitsablauf verändert.</p>
<div class="hero-meta">
<div><b>E-Rechnung</b><span>ZUGFeRD / XRechnung · extern validiert</span></div>
<div><b>Echtzeit</b><span>Live-Vorschau &amp; Benachrichtigungen</span></div>
<div><b>.NET 10</b><span>Moderne, geprüfte Plattform</span></div>
</div>
</div>
</header>
<!-- WAS NEU IST -->
<section>
<div class="wrap">
<div class="section-head">
<span class="kicker">Das Wichtigste auf einen Blick</span>
<h2>Die neuen Funktionen für Ihren Alltag</h2>
<p>Alle Neuerungen zielen auf dasselbe Ziel: weniger Fehler, mehr Überblick und Rechnungen, die auf Anhieb korrekt sind.</p>
</div>
<div class="grid">
<div class="card">
<div class="ico">👁️</div>
<h3>Live-Vorschau beim Bearbeiten</h3>
<p>Während Sie eine Rechnung bearbeiten, sehen Sie das fertige PDF sofort in Echtzeit — genau so, wie es der Kunde erhält. Kein Zwischenspeichern, kein Raten mehr.</p>
<span class="tag">Rechnungen &amp; Zahlungserinnerungen</span>
</div>
<div class="card">
<div class="ico">🧮</div>
<h3>Alle Beträge serverseitig berechnet</h3>
<p>Summen, Mehrwertsteuer, §13b-Umkehr und offene Beträge rechnet ab sofort der Server — geprüft und einheitlich. Der Bildschirm zeigt immer denselben Stand wie das PDF.</p>
<span class="tag">Keine Rechenfehler mehr</span>
</div>
<div class="card">
<div class="ico">🔔</div>
<h3>Benachrichtigungen in Echtzeit</h3>
<p>„Rechnung R2026-0001 wurde an den Kunden versandt." — Erfolg <em>und</em> Fehler erscheinen sofort als deutlich lesbare Meldung. Kein Nachschauen in Listen mehr.</p>
<span class="tag">Sofortiges Feedback</span>
</div>
<div class="card">
<div class="ico">🧾</div>
<h3>Extern geprüfte E-Rechnung</h3>
<p>Rechnungen werden automatisch als ZUGFeRD 2.4 / Factur-X (DATEV) ausgegeben — oder, sobald eine Leitweg-ID hinterlegt ist, als XRechnung 3.0 für den Behördenversand. Beide Formate sind bei einem externen Prüfdienst als fehlerfrei (0 Fehler) und PDF/A-3-konform bestätigt.</p>
<span class="tag">ZUGFeRD 2.4 · XRechnung 3.0 · Extern validiert</span>
</div>
<div class="card">
<div class="ico">🕓</div>
<h3>Änderungshistorie &amp; Verwerfen</h3>
<p>Jede Änderung an einem Entwurf wird protokolliert. Über „Änderungshistorie" sehen Sie, was passiert ist, und mit „Änderungen verwerfen" kehren Sie jederzeit zum gespeicherten Stand zurück.</p>
<span class="tag">Volle Nachvollziehbarkeit</span>
</div>
<div class="card">
<div class="ico">🏦</div>
<h3>Mehr Bankformate</h3>
<p>Kontoauszüge werden jetzt auch im modernen ISO-20022-Format (CAMT) automatisch erkannt und eingelesen — zusätzlich zum bewährten MT940. Das Format wird selbstständig erkannt.</p>
<span class="tag">CAMT + MT940</span>
</div>
</div>
</div>
</section>
<!-- SPOTLIGHT: Live-Editor -->
<section style="padding-top:12px">
<div class="wrap">
<div class="spot">
<div class="spot-text">
<span class="chip">Highlight</span>
<h2>Der Rechnungs-Editor, der mitdenkt</h2>
<p>Früher rechnete der Browser — heute ist der Server die einzige verbindliche Quelle. Das klingt technisch, bedeutet für Sie aber vor allem: Was Sie sehen, stimmt. Immer.</p>
<p>Gilt für <strong>alle Rechnungsarten</strong> (Regel-, Abschlags-, Schluss- und Stornorechnung) und <strong>alle Mahnstufen</strong> — das Online-Bild und das PDF sind garantiert identisch, bis hin zur Positionsnummerierung.</p>
</div>
<div class="spot-card">
<h3>Was der Editor jetzt automatisch tut</h3>
<ul>
<li>Rechnet Netto, MwSt. und Brutto sofort korrekt neu</li>
<li>Prüft E-Mail, Adresse, Positionen und Steuersätze live</li>
<li>Erzeugt die PDF-Vorschau direkt aus dem aktuellen Stand</li>
<li>Nummeriert Positionen auch nach Umsortieren korrekt durch</li>
<li>Warnt rechtzeitig, bevor ein Entwurf abläuft</li>
<li>Speichert erst final, wenn Sie es bestätigen</li>
</ul>
</div>
</div>
</div>
</section>
<!-- ONLINE-EDITOR IM DETAIL -->
<section>
<div class="wrap">
<div class="section-head">
<span class="kicker">Der Online-Editor im Detail</span>
<h2>Rechnungen direkt im Browser erstellen</h2>
<p>Der neue Rechnungs-Editor führt Sie Schritt für Schritt zur fertigen Rechnung — komfortabel zu bedienen und dabei jederzeit rechnerisch abgesichert. Er gilt für alle Rechnungsarten (Regel-, Abschlags-, Schluss- und Stornorechnung).</p>
</div>
<div class="steps">
<div class="step"><div class="n">BEARBEITEN</div><h4>Direkt im Feld</h4><p>Texte und Positionen bearbeiten Sie direkt an Ort und Stelle — ein Klick genügt.</p></div>
<div class="step"><div class="n">EMPFÄNGER</div><h4>Adresse als Formular</h4><p>Die Rechnungsadresse erfassen Sie strukturiert (Name, Straße, PLZ, Ort, Land, optional USt-IdNr., bei Behörden die Leitweg-ID) — vorausgefüllt aus den Kundendaten, ergänzt um Leistungsdatum bzw. Leistungszeitraum. Ein Hinweis zeigt, ob alles für die DATEV-/E-Rechnung passt; ist eine Leitweg-ID gesetzt, wird automatisch als XRechnung statt ZUGFeRD ausgegeben. Für Privatpersonen bleibt die USt-IdNr. einfach leer.</p></div>
<div class="step"><div class="n">ORDNEN</div><h4>Blöcke &amp; Reihenfolge</h4><p>Positionen sind je Auftrag in Abschnitten gebündelt und lassen sich per Ziehen neu sortieren; die Nummerierung passt sich automatisch an.</p></div>
<div class="step"><div class="n">RECHNEN</div><h4>Summen &amp; Steuer live</h4><p>Netto, Mehrwertsteuer, Brutto und die §13b-Umkehr werden bei jeder Änderung sofort und geprüft neu berechnet.</p></div>
<div class="step"><div class="n">PRÜFEN</div><h4>Vorschau auf Knopfdruck</h4><p>Die PDF-Vorschau entsteht direkt aus dem aktuellen Stand — was Sie sehen, ist exakt das, was der Kunde erhält.</p></div>
</div>
<!-- SET-PREIS-VARIANTEN -->
<div class="setpanel">
<div class="ttl"><span class="badge">📦</span><h3>Set-Preis: drei Wege, Positionen zusammenzufassen</h3></div>
<p>Oft sollen mehrere Einzelpositionen zu <em>einem</em> Set-Preis zusammengefasst werden — etwa als Pauschale pro Auftrag. Dafür gibt es drei klar getrennte Funktionen. Bei allen bleibt die <strong>Rechnungssumme unverändert</strong>; die Set-Zeile trägt genau den Wert der zusammengefassten Positionen.</p>
<div class="setgrid">
<div class="setvar">
<div class="num">1</div>
<h4>Einzelne Set-Position zusammenfassen<small>Zeilen-Schaltfläche · direkt an der Position</small></h4>
<p>Für eine einzelne Set-Position: Der Set-Kopf übernimmt die Summe seiner zugehörigen Teilpositionen, deren Einzelpreise werden dann leer dargestellt (kein Preis, nicht 0,00 €).</p>
<span class="kv">Wirkt auf: eine markierte Set-Position</span>
<span class="pill one">Einmalig — nicht per Klick umkehrbar</span>
</div>
<div class="setvar">
<div class="num">2</div>
<h4>„Set mit Preis"<small>Menü · ganzer Auftragsblock</small></h4>
<p>Für jeden Auftragsblock wird oben eine hervorgehobene Set-Zeile mit dem Gesamtwert des Blocks eingefügt. Die bisherigen Einzelpositionen <strong>bleiben sichtbar</strong>, jedoch ohne Einzelpreis (leeres Preisfeld). Die eingefügte Set-Zeile ist eine echte, nachträglich editierbare Position.</p>
<span class="kv">Wirkt auf: jeden Auftragsblock · Positionen bleiben erhalten</span>
<span class="pill rev">Einmalige Umwandlung</span>
</div>
<div class="setvar">
<div class="num">3</div>
<h4>„Nur Set mit Preis"<small>Menü · ganzer Auftragsblock</small></h4>
<p>Wie „Set mit Preis" — aber die Einzelpositionen werden <strong>vollständig entfernt</strong>. Es bleibt allein die eine Set-Zeile mit dem Gesamtpreis des Blocks stehen. Ideal für eine schlanke Pauschal-Darstellung.</p>
<span class="kv">Wirkt auf: jeden Auftragsblock · Positionen werden entfernt</span>
<span class="pill rev">Einmalige Umwandlung</span>
</div>
</div>
<div class="setnote">
<b>Gut zu wissen:</b> Die beiden Menü-Varianten (2 &amp; 3) sind bewusste, <b>einmalige Umwandlungen</b> — es gibt keinen Umschalter zurück. Möchten Sie den Ausgangszustand wiederherstellen, verwerfen Sie einfach den Entwurf („Änderungen verwerfen"), oder passen Sie die entstandene Set-Zeile von Hand an. In jedem Fall gilt: die <b>Gesamtsumme ändert sich nicht</b>, und die PDF-Ausgabe zeigt exakt dasselbe wie der Online-Editor.
</div>
</div>
</div>
</section>
<!-- BEFORE / AFTER -->
<section class="compare">
<div class="wrap">
<div class="section-head">
<span class="kicker">Alt gegen Neu</span>
<h2>Was sich konkret verbessert hat</h2>
<p>Ein direkter Vergleich der bisherigen Lösung mit der neuen Implementierung.</p>
</div>
<div class="table-scroll">
<table>
<thead>
<tr><th>Bereich</th><th>Bisher (Legacy)</th><th>Neu</th></tr>
</thead>
<tbody>
<tr>
<td class="feat">Rechnungs­vorschau</td>
<td class="old">Kein Live-PDF — Ergebnis erst nach dem Speichern sichtbar</td>
<td class="new">Echtzeit-PDF-Vorschau schon während der Bearbeitung</td>
</tr>
<tr>
<td class="feat">Berechnung</td>
<td class="old">Beträge im Browser gerechnet — Abweichungen möglich</td>
<td class="new">Alle Werte serverseitig geprüft &amp; einheitlich berechnet</td>
</tr>
<tr>
<td class="feat">Rückmeldungen</td>
<td class="old">Keine aktive Meldung — Status nur durch Nachschauen</td>
<td class="new">Sofortige Erfolgs- und Fehlermeldungen in Echtzeit</td>
</tr>
<tr>
<td class="feat">Fehler im Hintergrund</td>
<td class="old">Nur im Protokoll — für den Nutzer unsichtbar</td>
<td class="new">Werden dem Nutzer verständlich angezeigt</td>
</tr>
<tr>
<td class="feat">Rechnungsformat</td>
<td class="old">Reines PDF</td>
<td class="new">Zusätzlich extern validierte E-Rechnung (ZUGFeRD/DATEV oder XRechnung für Behörden) — 0 Fehler, PDF/A-3 bestätigt</td>
</tr>
<tr>
<td class="feat">Änderungsverlauf</td>
<td class="old">Nicht vorhanden</td>
<td class="new">Vollständige Historie &amp; gezieltes Verwerfen je Entwurf</td>
</tr>
<tr>
<td class="feat">Kontoauszüge</td>
<td class="old">Nur MT940</td>
<td class="new">MT940 <em>und</em> CAMT (ISO 20022) mit Auto-Erkennung</td>
</tr>
<tr>
<td class="feat">System-Überblick</td>
<td class="old">Kein Einblick in den Systemzustand</td>
<td class="new">Admin-/Status-Modul mit Live-Prüfungen (für berechtigte Nutzer)</td>
</tr>
<tr>
<td class="feat">Plattform</td>
<td class="old">Ältere VB-Codebasis</td>
<td class="new">Modernes .NET 10 — schneller, gepflegt, umfangreich getestet</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- KEY USER -->
<section class="keyuser">
<div class="wrap">
<div class="section-head">
<span class="kicker" style="color:var(--accent-2)">Für den Key-User</span>
<h2>Mehr Kontrolle hinter den Kulissen</h2>
<p>Diese Punkte betreffen vor allem Sie als Key-User — sie sorgen dafür, dass der Betrieb stabil, nachvollziehbar und überprüfbar bleibt.</p>
</div>
<div class="ku-grid">
<div class="ku">
<h3><span></span> Admin- &amp; Status-Modul</h3>
<p>Ein eigenes Modul zeigt den Zustand des Systems: Server, Datenbank, Schlüsseltresor, Speicher und die ERP-Anbindung werden live geprüft. Inklusive Test-E-Mail-Funktion — sichtbar nur für berechtigte Nutzer.</p>
</div>
<div class="ku">
<h3><span></span> Durchgängige Nachvollziehbarkeit</h3>
<p>Jeder wichtige Geschäftsvorfall — erstellt, versandt, importiert, fehlgeschlagen — wird als Ereignis erfasst und in verständliche Meldungen übersetzt.</p>
</div>
<div class="ku">
<h3><span></span> Überwachung &amp; Diagnose</h3>
<p>Moderne Telemetrie (OpenTelemetry) misst Abläufe, Laufzeiten und Fehler. Probleme lassen sich damit früher erkennen und schneller eingrenzen.</p>
</div>
<div class="ku">
<h3><span></span> Automatischer ERP-Abgleich</h3>
<p>Der Abgleich mit dem ERP-System (mfr) läuft zuverlässig im Hintergrund direkt in der Anwendung — mit automatischer Wiederholung bei kurzzeitigen Störungen.</p>
</div>
<div class="ku">
<h3><span></span> Sichere Konfiguration</h3>
<p>Zugangsdaten liegen im zentralen Azure Key Vault. Eine Test-Schutzfunktion verhindert, dass in Test-Umgebungen versehentlich echte Kunden angeschrieben werden.</p>
</div>
<div class="ku">
<h3><span></span> Umfassend getestet</h3>
<p>Die Kernlogik ist durch eine breite, automatisierte Testabdeckung abgesichert — erfolgreiche wie fehlerhafte Abläufe werden geprüft, bevor Änderungen live gehen.</p>
</div>
</div>
</div>
</section>
<!-- UNVERÄNDERT / VERTRAUT -->
<section>
<div class="wrap">
<div class="section-head">
<span class="kicker">Vertraut geblieben</span>
<h2>Was sich für Sie <em>nicht</em> ändert</h2>
<p>Modernisiert wurde die Technik — nicht Ihre Arbeitsweise.</p>
</div>
<div class="grid">
<div class="card"><div class="ico">🗂️</div><h3>Gewohnte Module</h3><p>Rechnungen, Zahlungserinnerungen, Anfragen, Banking und Berichte finden Sie an denselben Stellen wie bisher.</p></div>
<div class="card"><div class="ico">📄</div><h3>Vertrautes Layout</h3><p>Briefkopf, Adressfenster und Rechnungslayout wurden 1:1 übernommen — Ihre Dokumente sehen aus wie gewohnt.</p></div>
<div class="card"><div class="ico">🔐</div><h3>Gleiche Anmeldung</h3><p>Login und Berechtigungen bleiben unverändert. Neue Funktionen erscheinen nur dort, wo Sie dafür berechtigt sind.</p></div>
</div>
</div>
</section>
<footer>
<div class="wrap">
<p><b>Fuchs Intranet</b> — Neue Implementierung · Stand Juli 2026<br>
Sebastian Fuchs Bad und Heizung GmbH &amp; Co. KG · Bereitgestellt von ProcessWeb</p>
</div>
</footer>
</body>
</html>
Binary file not shown.
+1
View File
@@ -22,6 +22,7 @@
<ProjectReference Include="..\OCORE_web\OCORE_web\OCORE_web.csproj" />
<ProjectReference Include="..\OCORE_web_pdf\OCORE_web_pdf.csproj" />
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
<ProjectReference Include="..\eRechnungLib\src\eRechnungLib\eRechnungLib.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="Data\**" CopyToOutputDirectory="PreserveNewest" />
+6
View File
@@ -121,6 +121,12 @@ public class Program
// (see appsettings.Development.json) so real tenant-owners/end-customers are never emailed.
builder.Services.Configure<FuchsEmailSettings>(builder.Configuration.GetSection("Fuchs:Email"));
builder.Services.Configure<StartupSelfTestSettings>(builder.Configuration.GetSection("Fuchs:StartupChecks"));
// eRechnung (ZUGFeRD/Factur-X) output + external formal-conformance validation seam.
builder.Services.Configure<ERechnungSettings>(builder.Configuration.GetSection("Fuchs:ERechnung"));
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.AddScoped<IComService, ProcessWebComService>();
// Holds the one-shot startup self-test result for the lifetime of the process so the Admin
+307
View File
@@ -0,0 +1,307 @@
using System.Globalization;
using eRechnungLib;
using eRechnungLib.Model;
using eRechnungLib.Model.CodeLists;
using Fuchs.intranet;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.Services;
/// <summary>
/// Maps a Fuchs <see cref="FdsInvoiceData"/> onto the strongly-typed EN 16931 invoice model of
/// <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 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
{
/// <summary>Builds an <see cref="EInvoice"/> (EN 16931 model) from the Fuchs invoice data.</summary>
/// <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);
var model = new Invoice
{
InvoiceNumber = NonEmpty(reg?.getString("InvoiceId"), invoice.Id, "ENTWURF"),
IssueDate = IssueDate(reg),
CurrencyCode = CurrencyCode.Eur,
Seller = BuildSeller(seller),
Buyer = BuildBuyer(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));
AddLines(model, invoice, reverseCharge);
if (reverseCharge)
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(ERechnungSellerSettings s)
{
var seller = new TradeParty
{
Name = s.Name,
Address = new PostalAddress
{
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) },
};
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;
}
private static TradeParty BuildBuyer(FdsInvoiceData invoice)
{
var addr = invoice.RecipientAddress;
if (addr is null)
{
// No structured address yet (older draft): best-effort from the free-text block so a
// model can still be produced. The country defaults to DE; refine once structured
// capture is in place. EN 16931 validation will flag any remaining gaps.
var lines = (invoice.InvoiceRegistration?.getString("SendToAddress") ?? "")
.Replace("\r\n", "\n").Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToArray();
return new TradeParty
{
Name = lines.Length > 0 ? lines[0] : "Rechnungsempfänger",
Address = new PostalAddress { Country = CountryCode.Germany },
};
}
var buyer = new TradeParty
{
Name = NonEmpty(addr.Name, "Rechnungsempfänger"),
Address = new PostalAddress
{
Line1 = NullIfEmpty(addr.Street),
Line2 = NullIfEmpty(addr.AddressLine2),
PostalCode = NullIfEmpty(addr.PostalCode),
City = NullIfEmpty(addr.City),
Country = NormalizeCountry(addr.CountryCode),
},
};
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, ERechnungSellerSettings s)
{
var payment = new PaymentInstructions
{
MeansCode = PaymentMeansCode.SepaCreditTransfer,
RemittanceInformation = invoice.InvoiceRegistration?.getString("InvoiceId") is { Length: > 0 } inv ? inv : null,
};
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;
foreach (var item in invoice.InvoiceItems)
{
string name = FirstNonEmpty(Str(item, "title"), Str(item, "nme"), Str(item, "text"), Str(item, "t"));
string desc = Str(item, "desc");
if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(desc)) name = "Position";
decimal qty = Dec(item, "qty", "q", "Quantity") ?? 1m;
if (qty == 0m) qty = 1m;
decimal net = Dec(item, "total_net", "value_total", "vt") ?? 0m;
decimal? unit = Dec(item, "price_net", "value", "v");
decimal rate = Dec(item, "vat") ?? 0m;
id++;
var line = new InvoiceLine
{
Id = id.ToString(CultureInfo.InvariantCulture),
Quantity = qty,
UnitCode = UnitCode.One,
NetPrice = unit ?? (qty != 0 ? decimal.Round(net / qty, 2, MidpointRounding.AwayFromZero) : net),
NetAmount = net,
VatCategory = reverseCharge ? VatCategoryCode.ReverseCharge
: rate > 0 ? VatCategoryCode.StandardRate : VatCategoryCode.ZeroRatedGoods,
VatRate = reverseCharge ? 0m : rate,
Item = new TradeItem { Name = Truncate(name, 500), Description = NullIfEmpty(desc) },
};
model.Lines.Add(line);
}
// EN 16931 requires at least one line (BR-16); synthesise one from the invoice total when
// the item list is empty (e.g. a lump-sum invoice).
if (model.Lines.Count == 0)
{
decimal net = ParseInvariant(invoice.InvoiceRegistration?.getString("InvoiceBalance_net")) ?? 0m;
decimal rate = ParseInvariant(invoice.InvoiceRegistration?.getString("InvoiceVAT_1")) ?? 0m;
model.Lines.Add(new InvoiceLine
{
Id = "1",
Quantity = 1m,
UnitCode = UnitCode.One,
NetPrice = net,
NetAmount = net,
VatCategory = reverseCharge ? VatCategoryCode.ReverseCharge
: rate > 0 ? VatCategoryCode.StandardRate : VatCategoryCode.ZeroRatedGoods,
VatRate = reverseCharge ? 0m : rate,
Item = new TradeItem { Name = NonEmpty(invoice.InvoiceRegistration?.getString("InvoiceTitle"), "Leistung") },
});
}
}
/// <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)
{
foreach (var key in new[] { "DateFinalized", "DateCreated" })
if (reg?.getString(key) is { Length: > 0 } s && DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt))
return DateOnly.FromDateTime(dt);
return DateOnly.FromDateTime(DateTime.Today);
}
/// <summary>Normalises a raw country value (ISO alpha-2, or a German/English name) to a country code.</summary>
internal static CountryCode NormalizeCountry(string? raw)
{
string s = (raw ?? "").Trim();
if (s.Length == 2 && s.All(char.IsLetter)) return new CountryCode(s);
return s.ToLowerInvariant() switch
{
"deutschland" or "germany" => CountryCode.Germany,
"österreich" or "oesterreich" or "austria" => CountryCode.Austria,
"schweiz" or "switzerland" or "suisse" => CountryCode.Switzerland,
"" => CountryCode.Germany,
_ => CountryCode.Germany,
};
}
private static string Str(IDictionary<string, object?> d, string key)
=> d.TryGetValue(key, out var v) && v != null ? Convert.ToString(v, CultureInfo.InvariantCulture)?.Trim() ?? "" : "";
private static decimal? Dec(IDictionary<string, object?> d, params string[] keys)
{
foreach (var k in keys)
if (d.TryGetValue(k, out var v) && v != null)
{
string s = Convert.ToString(v, CultureInfo.InvariantCulture)?.Replace("%", "").Trim() ?? "";
if (decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var dec)) return dec;
if (decimal.TryParse(s.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out dec)) return dec;
}
return null;
}
private static decimal? ParseInvariant(string? s)
=> decimal.TryParse((s ?? "").Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null;
private static string FirstNonEmpty(params string[] values) => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "";
private static string NonEmpty(params string?[] values) => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "";
private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max];
}
+122
View File
@@ -0,0 +1,122 @@
using System.Diagnostics;
using eRechnungLib;
using eRechnungLib.Profiles;
using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Fuchs.Services;
/// <summary>
/// Produces the ZUGFeRD/Factur-X hybrid PDF for a finalized invoice by mapping
/// <see cref="FdsInvoiceData"/> to the EN 16931 model and embedding the CII XML into the
/// FuchsPdf-rendered <b>visual</b> PDF (ADR 0012 — eRechnungLib owns the single PDF/A-3 layer).
/// </summary>
public interface IERechnungService
{
/// <summary>Whether eRechnung emission is switched on (<c>Fuchs:ERechnung:Enabled</c>).</summary>
bool Enabled { get; }
/// <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. When external validation is enabled
/// and reports a failure with <c>FailOnError</c>, the hybrid is withheld (null → fallback) too.
/// </summary>
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, IERechnungValidator validator,
ILogger<ERechnungService> logger)
{
_settings = settings.Value;
_validator = validator;
_logger = logger;
}
public bool Enabled => _settings.Enabled;
public async Task<byte[]?> TryBuildHybridPdfAsync(FdsInvoiceData invoice, byte[] rawVisualPdf)
{
ArgumentNullException.ThrowIfNull(invoice);
if (!_settings.Enabled) return null;
if (rawVisualPdf is null || rawVisualPdf.Length == 0) return null;
var sw = Stopwatch.StartNew();
using var act = FuchsTelemetry.StartActivity("invoice.erechnung");
act?.SetTag("fuchs.invoice.id", invoice.Id);
try
{
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)
{
_logger.LogError("eRechnung: conversion produced no output for invoice {Id}. Findings: {Findings}",
invoice.Id, result.Validation);
return null;
}
if (!result.Validation.IsValid)
_logger.LogWarning("eRechnung: invoice {Id} produced with library validation findings: {Findings}",
invoice.Id, result.Validation);
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, 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)
{
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "eRechnung: hybrid build failed for invoice {Id}; falling back to plain PDF/A", invoice.Id);
return null;
}
}
private static ZugferdProfile ParseProfile(string? profile) => (profile ?? "").Trim().ToUpperInvariant() switch
{
"EXTENDED" => ZugferdProfile.Extended,
"BASIC" => ZugferdProfile.Basic,
"XRECHNUNG" => ZugferdProfile.XRechnung,
_ => ZugferdProfile.EN16931,
};
}
+97
View File
@@ -0,0 +1,97 @@
namespace Fuchs.Services;
/// <summary>
/// eRechnung (ZUGFeRD/Factur-X) output settings, bound from appsettings.json → "Fuchs:ERechnung".
/// </summary>
/// <remarks>
/// Per ADR 0005 the invoice visual PDF is produced by <c>FuchsPdf</c> (render-only, no Spire
/// PDF/A) and handed to <c>eRechnungLib.ToZugferd(...)</c>, which owns the single PDF/A-3 layer
/// and embeds the CII XML. This is the gate that turns that emission on and configures it.
/// </remarks>
public sealed class ERechnungSettings
{
/// <summary>
/// When <see langword="true"/>, finalized invoices are emitted as a ZUGFeRD/Factur-X hybrid
/// PDF/A-3. Kept <see langword="false"/> until the <c>FdsInvoiceData</c> → model mapping is
/// wired, so the app keeps producing the plain PDF/A until then.
/// </summary>
public bool Enabled { get; set; }
/// <summary>The ZUGFeRD profile to emit. Defaults to <c>EN16931</c> (the minimum DATEV accepts
/// 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".
/// </summary>
/// <remarks>
/// The concrete service URL/contract is provided by an online service that is not yet available;
/// this is the configurable seam so only the URL has to be supplied later. While
/// <see cref="ServiceUrl"/> is empty, verification is reported as "not configured / skipped".
/// </remarks>
public sealed class ERechnungValidationSettings
{
/// <summary>Whether to call the external validation service after producing a hybrid PDF.</summary>
public bool Enabled { get; set; }
/// <summary>Base URL of the online veraPDF/ZUGFeRD validation service (empty until provisioned).</summary>
public string ServiceUrl { get; set; } = "";
/// <summary>
/// When <see langword="true"/>, a validation error withholds the invoice output rather than
/// shipping it with findings. Defaults to <see langword="false"/> (report-only).
/// </summary>
public bool FailOnError { get; set; }
}
+126
View File
@@ -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];
}
+23
View File
@@ -143,6 +143,29 @@ public class FuchsPdfService : IPdfService
}
}
public byte[] DocToPdfBytesRaw(Document doc)
{
var sw = Stopwatch.StartNew();
using var act = FuchsTelemetry.StartActivity("pdf.render.raw");
try
{
byte[] bytes = FuchsPdf.DocToPdfBytesRaw(doc);
sw.Stop();
FuchsTelemetry.PdfRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
new KeyValuePair<string, object?>("operation", "pdf-raw"));
act?.SetTag("fuchs.pdf.bytes", bytes.Length);
_logger.LogDebug("DocToPdfBytesRaw rendered {Bytes} bytes in {Ms} ms", bytes.Length, sw.ElapsedMilliseconds);
return bytes;
}
catch (Exception ex)
{
sw.Stop();
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "DocToPdfBytesRaw failed after {Ms} ms", sw.ElapsedMilliseconds);
throw;
}
}
public async Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc)
{
var sw = Stopwatch.StartNew();
+6
View File
@@ -20,6 +20,12 @@ public interface IPdfService
/// <summary>Renders a MigraDoc Document to a PDF/A byte array.</summary>
byte[] DocToPdfBytes(Document doc);
/// <summary>
/// Renders a MigraDoc Document to a plain (non-PDF/A) PDF byte array — the visual PDF used as
/// input to eRechnung (ZUGFeRD) conversion, which owns the PDF/A-3 layer itself.
/// </summary>
byte[] DocToPdfBytesRaw(Document doc);
/// <summary>Renders a MigraDoc Document to an image collection for preview.</summary>
Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc);
+28 -1
View File
@@ -95,7 +95,12 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
switch (d.Target)
{
case "email": return SetNewText(s, "invoiceemail", d, ref oldValue, ref newValue);
case "address": return SetNewText(s, "invoiceaddress", d, ref oldValue, ref newValue);
case "address":
// Structured dialog form posts a JSON object; a plain string keeps the legacy
// free-text path working (backward compatibility).
return d.Value is JObject
? SetSendToAddress(s, d, ref oldValue, ref newValue)
: SetNewText(s, "invoiceaddress", d, ref oldValue, ref newValue);
case "title": return SetNewText(s, "invoicetitle", d, ref oldValue, ref newValue);
case "provisionperiod": return SetNewText(s, "provisionperiod", d, ref oldValue, ref newValue);
case "provisionlocation":
@@ -157,6 +162,26 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
private static string ContactLabel(string name, string email) =>
string.IsNullOrEmpty(name) ? email : string.IsNullOrEmpty(email) ? name : $"{name} <{email}>";
/// <summary>
/// Applies a structured recipient address: stores it as JSON inside the <c>CustomValues</c>
/// blob (interim persistence, no schema change) and composes the multi-line free-text block
/// into <c>invoiceaddress</c> so the PDF and the <c>SendToAddress</c> column are unchanged.
/// </summary>
private static bool SetSendToAddress(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (d.Value is not JObject vo) return false;
var addr = InvoiceRecipientAddress.FromJson(vo);
oldValue = Str(s.New["invoiceaddress"]);
JObject cvo = (JObject)TryParseObject(Str(s.New["CustomValues"])).DeepClone();
cvo[InvoiceRecipientAddress.CustomValuesKey] = addr.ToJson();
s.New["CustomValues"] = cvo.ToString(Newtonsoft.Json.Formatting.None);
newValue = addr.Compose();
s.New["invoiceaddress"] = newValue;
return true;
}
/// <summary>Replaces (or inserts) a whole block — the editor re-emits an edited block's line arrays as one delta.</summary>
private static bool ReplaceBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
@@ -590,6 +615,8 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
["InvoiceBalance"] = session.Sums.TotalGross,
["InvoiceBalance_net"] = session.Sums.TotalNet,
["CustomValues"] = Str(session.New["CustomValues"]),
["SendToAddressJson"] = InvoiceRecipientAddress.FromCustomValues(Str(session.New["CustomValues"]))?
.ToJson().ToString(Newtonsoft.Json.Formatting.None) ?? "",
["InvoiceOptions"] = BuildInvoiceOptions(session),
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
};
+141
View File
@@ -0,0 +1,141 @@
using System.Text;
using Newtonsoft.Json.Linq;
namespace Fuchs.Services;
/// <summary>
/// Structured invoice-recipient (buyer) address held in the draft cache and persisted as JSON
/// (interim: inside the <c>CustomValues</c> blob under <c>sendToAddress</c>; a dedicated
/// <c>SendToAddressJson</c> column is planned — see ADR 0012 / the SQL plan). It replaces the
/// former free-text <c>invoiceaddress</c> as the source of truth, while a composed free-text
/// block (<see cref="Compose"/>) keeps the existing PDF/<c>SendToAddress</c> path unchanged.
/// </summary>
/// <remarks>
/// The field set targets EN 16931 / DATEV: <see cref="Name"/> (BT-44), <see cref="Contact"/>
/// (z.Hd.), <see cref="AddressLine2"/> (BT-51), <see cref="Street"/> (BT-50),
/// <see cref="PostalCode"/> (BT-53), <see cref="City"/> (BT-52), <see cref="CountryCode"/>
/// (BT-55, mandatory), <see cref="VatId"/> (BT-48, optional — empty for a private person, which
/// must stay effortless: B2C invoices need no VAT id and are still EN 16931-valid).
/// </remarks>
public sealed class InvoiceRecipientAddress
{
/// <summary>The JSON key under which the structured address rides inside <c>CustomValues</c>.</summary>
public const string CustomValuesKey = "sendToAddress";
public string Name { get; set; } = "";
public string Contact { get; set; } = "";
public string AddressLine2 { get; set; } = "";
public string Street { get; set; } = "";
public string PostalCode { get; set; } = "";
public string City { get; set; } = "";
/// <summary>ISO 3166-1 alpha-2 country code (BT-55). Defaults to <c>DE</c>.</summary>
public string CountryCode { get; set; } = "DE";
/// <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)
{
o ??= new JObject();
return new InvoiceRecipientAddress
{
Name = S(o, "name"),
Contact = S(o, "contact"),
AddressLine2 = S(o, "line2", "addressLine2"),
Street = S(o, "street"),
PostalCode = S(o, "postalCode", "zip", "plz"),
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"),
};
}
/// <summary>Parses the structured address out of a <c>CustomValues</c> JSON blob, if present.</summary>
public static InvoiceRecipientAddress? FromCustomValues(string? customValuesJson)
{
if (string.IsNullOrWhiteSpace(customValuesJson)) return null;
try
{
var cv = JObject.Parse(customValuesJson);
return cv[CustomValuesKey] is JObject a ? FromJson(a) : null;
}
catch { return null; }
}
/// <summary>Serialises to a JSON object for the cache / <c>CustomValues</c> blob.</summary>
public JObject ToJson() => new()
{
["name"] = Name,
["contact"] = Contact,
["line2"] = AddressLine2,
["street"] = Street,
["postalCode"] = PostalCode,
["city"] = City,
["countryCode"] = CountryCode,
["vatId"] = VatId,
["leitwegId"] = LeitwegId,
};
/// <summary>
/// Composes the multi-line free-text postal block (for the PDF and the legacy
/// <c>SendToAddress</c> column). The VAT id is intentionally excluded — it is not part of the
/// postal address, only of the structured eRechnung data (BT-48). Foreign countries append
/// the country code line; domestic (DE) omits it, matching the current letter layout.
/// </summary>
public string Compose()
{
var sb = new StringBuilder();
void Line(string? v) { if (!string.IsNullOrWhiteSpace(v)) sb.Append(sb.Length > 0 ? "\n" : "").Append(v!.Trim()); }
Line(Name);
if (!string.IsNullOrWhiteSpace(Contact)) Line($"z.Hd. {Contact.Trim()}");
Line(AddressLine2);
Line(Street);
string cityLine = $"{PostalCode} {City}".Trim();
Line(cityLine);
if (!string.IsNullOrWhiteSpace(CountryCode) && !CountryCode.Equals("DE", StringComparison.OrdinalIgnoreCase))
Line(CountryCode.ToUpperInvariant());
return sb.ToString();
}
/// <summary>
/// The EN 16931 fields still missing for a formally valid buyer (BG-7). Buyer name (BT-44)
/// and country code (BT-55) are mandatory; a postal address needs a city or post code. VAT id
/// is <b>not</b> required (a private person is valid without one). An empty result means the
/// recipient is EN 16931 / DATEV conformant — the editor surfaces this as a hint.
/// </summary>
public IReadOnlyList<string> MissingForEn16931()
{
var missing = new List<string>();
if (string.IsNullOrWhiteSpace(Name)) missing.Add("Name");
if (string.IsNullOrWhiteSpace(CountryCode)) missing.Add("Land");
if (string.IsNullOrWhiteSpace(City) && string.IsNullOrWhiteSpace(PostalCode)) missing.Add("Ort/PLZ");
return missing;
}
/// <summary>True when the recipient satisfies the EN 16931 mandatory buyer fields.</summary>
public bool IsEn16931Conformant => MissingForEn16931().Count == 0;
private static string S(JObject o, params string[] keys)
{
foreach (var k in keys)
if (o[k] is { } t && t.Type != JTokenType.Null)
return t.Type == JTokenType.String ? t.Value<string>()?.Trim() ?? "" : t.ToString().Trim();
return "";
}
}
+18 -5
View File
@@ -26,15 +26,17 @@ public class InvoiceService : IInvoiceService
private readonly IPdfService _pdf;
private readonly IBlobStorageService _blobStorage;
private readonly IEventService _events;
private readonly IERechnungService _erechnung;
private readonly ILogger<InvoiceService> _logger;
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
IEventService events, ILogger<InvoiceService> logger)
IEventService events, IERechnungService erechnung, ILogger<InvoiceService> logger)
{
_intranet = intranet;
_pdf = pdf;
_blobStorage = blobStorage;
_events = events;
_erechnung = erechnung;
_logger = logger;
}
@@ -133,13 +135,13 @@ public class InvoiceService : IInvoiceService
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
if (!change)
{
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
}
else
{
pl.Add(SQL_VarChar("@InvId", invId));
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
}
if (invoice.RawProvisionLocation.Length > 0)
@@ -282,8 +284,19 @@ public class InvoiceService : IInvoiceService
return doc;
}
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
=> Task.FromResult(_pdf.DocToPdfBytes(GenerateInvoicePdf(invoice, 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:
// eRechnungLib embeds the CII XML into the render-only visual PDF and owns the single
// PDF/A-3 layer (ADR 0012). Drafts/previews keep the plain PDF/A. Any failure falls back.
if (!draft && _erechnung.Enabled)
{
var hybrid = await _erechnung.TryBuildHybridPdfAsync(invoice, _pdf.DocToPdfBytesRaw(doc));
if (hybrid is { Length: > 0 }) return hybrid;
}
return _pdf.DocToPdfBytes(doc);
}
public async Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData invoice, bool draft,
string userAccountId, DatabaseSecurity dbSec)
+23
View File
@@ -69,6 +69,29 @@
"Telemetry": {
"Enabled": true,
"OtlpEndpoint": ""
},
"ERechnung": {
"Enabled": false,
"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": {
"Enabled": true,
"ServiceUrl": "https://api.processweb.de/api/eInvoice",
"FailOnError": false
}
}
},
"Fds": {
+29
View File
@@ -83,6 +83,34 @@ public class FdsInvoiceData
}
}
/// <summary>
/// Structured recipient (buyer) address for eRechnung (EN 16931) mapping, parsed from the
/// <c>CustomValues</c> JSON (interim persistence — see ADR 0012). Null for invoices that
/// predate structured capture; callers fall back to the free-text <c>SendToAddress</c> block.
/// </summary>
public Fuchs.Services.InvoiceRecipientAddress? RecipientAddress
{
get
{
// Prefer the dedicated SendToAddressJson column; fall back to the CustomValues blob
// (interim persistence and rows written before the column existed).
string col = InvoiceRegistration?.getString("SendToAddressJson") ?? "";
if (col.Length > 0)
{
try { return Fuchs.Services.InvoiceRecipientAddress.FromJson(JObject.Parse(col)); }
catch { /* fall through to CustomValues */ }
}
return Fuchs.Services.InvoiceRecipientAddress.FromCustomValues(
InvoiceRegistration?.getString("CustomValues") is { Length: > 0 } cv ? cv : RawCustomValues);
}
}
/// <summary>The structured recipient address serialised for the <c>SendToAddressJson</c> column
/// (from the posted CustomValues blob), or empty when the invoice has no structured address.</summary>
private string SendToAddressJsonForPersist() =>
Fuchs.Services.InvoiceRecipientAddress.FromCustomValues(RawCustomValues)?.ToJson()
.ToString(Newtonsoft.Json.Formatting.None) ?? "";
/// <summary>VAT rows keyed by percentage string (e.g. "19"), from InvoiceRegistration.</summary>
public Dictionary<string, Dictionary<string, object?>> VatRows
{
@@ -153,6 +181,7 @@ public class FdsInvoiceData
SQL_NVarChar("@SendToEmail", RawInvoiceEmail),
SQL_NVarChar("@ProvisionPeriod", RawProvisionPeriod, dbNull_IfEmpty: true),
SQL_NVarChar("@CustomValues", RawCustomValues, dbNull_IfEmpty: true),
SQL_NVarChar("@SendToAddressJson", SendToAddressJsonForPersist(), dbNull_IfEmpty: true),
SQL_Float("@InvoiceService_net", stringvalue: Sms?.nz("tscn") ?? "0"),
SQL_Float("@InvoiceService_VAT", stringvalue: Sms?.nz("tscvat") ?? "0"),
SQL_VarChar("@InvoiceOptions", BuildInvoiceOptions(), dbNull_IfEmpty: true)
+12 -4
View File
@@ -979,18 +979,26 @@ public static class FuchsPdf
}
}
/// <summary>Renders a MigraDoc Document to a PDF/A byte array.</summary>
public static byte[] DocToPdfBytes(Document doc)
/// <summary>
/// Renders a MigraDoc Document to a plain (non-PDF/A) PDF byte array. Fonts are embedded via
/// the <c>OCOREFontResolver</c>, but no PDF/A post-processing is applied. This is the visual
/// PDF fed into <c>eRechnungLib.ToZugferd(...)</c>, which owns the single PDF/A-3 conversion
/// for eRechnung output (avoiding a conflicting second PDF/A layer from Spire). See ADR 0005.
/// </summary>
public static byte[] DocToPdfBytesRaw(Document doc)
{
EnsureFontResolver();
var renderer = new PdfDocumentRenderer() { Document = doc };
renderer.RenderDocument();
using var ms = new MemoryStream();
renderer.PdfDocument.Save(ms, closeStream: false);
ms.Position = 0;
return OCORE.pdf._pdf.pdfAFileContent(ms.ToArray());
return ms.ToArray();
}
/// <summary>Renders a MigraDoc Document to a PDF/A byte array (Spire post-processing).</summary>
public static byte[] DocToPdfBytes(Document doc)
=> OCORE.pdf._pdf.pdfAFileContent(DocToPdfBytesRaw(doc));
/// <summary>Renders a MigraDoc Document to an ImageCollection for preview.</summary>
public static async Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollection(Document doc)
{
+114
View File
@@ -980,7 +980,121 @@ $inv.cSt = function (data) {
}
});
};
/* Country choices for the structured recipient-address dialog (ISO 3166-1 alpha-2, BT-55). */
$inv.adrCountries = [['DE', 'Deutschland'], ['AT', 'Österreich'], ['CH', 'Schweiz'], ['FR', 'Frankreich'],
['NL', 'Niederlande'], ['BE', 'Belgien'], ['LU', 'Luxemburg'], ['IT', 'Italien'], ['ES', 'Spanien'],
['PL', 'Polen'], ['DK', 'Dänemark'], ['CZ', 'Tschechien'], ['GB', 'Großbritannien'], ['US', 'USA']];
/* Compose the free-text postal block (for the inline display) from the structured address —
mirrors the backend InvoiceRecipientAddress.Compose (the VAT id is never part of the block). */
$inv.composeAddress = function (a) {
a = a || {}; let lines = [];
let push = (v) => { v = ('' + (v || '')).trim(); if (v !== '') lines.push(v); };
push(a.name);
if (('' + (a.contact || '')).trim() !== '') push('z.Hd. ' + ('' + a.contact).trim());
push(a.line2 || a.addressLine2);
push(a.street);
push((('' + (a.postalCode || '')) + ' ' + ('' + (a.city || ''))).trim());
let cc = ('' + (a.countryCode || '')).toUpperCase();
if (cc !== '' && cc !== 'DE') push(cc);
return lines.join('\n');
};
/* The structured recipient address currently in effect: the backend keeps it in
CustomValues.sendToAddress (survives a draft refresh); new invoices seed it from
fds__prepInvoice's invoiceaddressData; otherwise start empty. */
$inv.adrCurrent = function (tbl) {
let cv = jObj((tbl.data('new') || {}).CustomValues, 'sendToAddress');
if (typeof cv === 'string' && cv.trim().charAt(0) === '{') { try { cv = JSON.parse(cv); } catch (e) { cv = null; } }
if (cv && typeof cv === 'object') return cv;
let ad = (tbl.data('admin') || {}).invoiceaddressData;
if (typeof ad === 'string' && ad.trim().charAt(0) === '{') { try { return JSON.parse(ad); } catch (e) { } }
else if (ad && typeof ad === 'object') { return ad; }
return {};
};
/* Structured recipient-address dialog (EN 16931 / DATEV). Posts the address delta as a JSON
object; the backend stores it, composes SendToAddress and keeps the PDF unchanged (ADR 0012).
B2C stays effortless: the USt-IdNr. is optional (empty = private person). */
$inv.eAddress = function (ev) {
let tbl = $inv.d.tbl(), cur = $inv.adrCurrent(tbl);
let flds = [
{ name: 'name', label: 'Name / Firma', type: 'text', value: cur.name || '', required: true },
{ name: 'contact', label: 'z.Hd. (optional)', type: 'text', value: cur.contact || '' },
{ name: 'street', label: 'Straße + Nr.', type: 'text', value: cur.street || '' },
{ name: 'line2', label: 'Adresszusatz (optional)', type: 'text', value: cur.line2 || cur.addressLine2 || '' },
{ name: 'postalCode', label: 'PLZ', type: 'text', value: cur.postalCode || '' },
{ name: 'city', label: 'Ort', type: 'text', value: cur.city || '' },
{ 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: 'leitwegId', label: 'Leitweg-ID (nur Behörden / B2G)', type: 'text', value: cur.leitwegId || '' }
];
let hint = $$.dc('adr-conformity').append([
$$.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 — '
+ '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, {
title: 'Rechnungsempfänger',
addcontent: hint,
success: function (res) {
let a = {
name: res.name || '', contact: res.contact || '', line2: res.line2 || '',
street: res.street || '', postalCode: res.postalCode || '', city: res.city || '',
countryCode: (res.countryCode || 'DE'), vatId: ('' + (res.vatId || '')).trim(),
leitwegId: ('' + (res.leitwegId || '')).trim()
};
let txt = $inv.composeAddress(a);
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
/* Structured delta — the invoice draft backend accepts a JSON object for 'address'. */
$inv.d.sync({ Target: 'address', Value: a });
}
});
};
/* 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) {
/* The recipient address and the service date/period are edited through structured dialogs
whenever an invoice draft is active; reminders (no invoice draft token) keep the plain
free-text editor. */
if (ev.data && !(ev.data instanceof jQuery) && $inv.d && typeof $inv.d.token === 'function' && $inv.d.token() !== '') {
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;
/* Single-line fields must stay plain text — the TinyMCE/html editor wraps the value in <p>
tags, which used to get posted and persisted verbatim (e.g. <p>18.06.2026</p> in the
@@ -474,3 +474,22 @@
}
}
/* Conformity hint shown inside the structured recipient-address dialog (EN 16931 / DATEV). */
.adr-conformity {
margin-top: 0.75rem;
padding: 0.6rem 0.75rem;
background: #f3f7fb;
border: 1px solid #cfe0f0;
border-radius: 4px;
font-size: 0.85rem;
line-height: 1.35;
.hd {
font-weight: 600;
margin-bottom: 0.2rem;
}
.tx {
color: #445;
}
}
+18
View File
@@ -493,3 +493,21 @@ table.if td.num {
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
/* Conformity hint shown inside the structured recipient-address dialog (EN 16931 / DATEV). */
.adr-conformity {
margin-top: 0.75rem;
padding: 0.6rem 0.75rem;
background: #f3f7fb;
border: 1px solid #cfe0f0;
border-radius: 4px;
font-size: 0.85rem;
line-height: 1.35;
}
.adr-conformity .hd {
font-weight: 600;
margin-bottom: 0.2rem;
}
.adr-conformity .tx {
color: #445;
}
+114
View File
@@ -1523,7 +1523,121 @@ $inv.cSt = function (data) {
}
});
};
/* Country choices for the structured recipient-address dialog (ISO 3166-1 alpha-2, BT-55). */
$inv.adrCountries = [['DE', 'Deutschland'], ['AT', 'Österreich'], ['CH', 'Schweiz'], ['FR', 'Frankreich'],
['NL', 'Niederlande'], ['BE', 'Belgien'], ['LU', 'Luxemburg'], ['IT', 'Italien'], ['ES', 'Spanien'],
['PL', 'Polen'], ['DK', 'Dänemark'], ['CZ', 'Tschechien'], ['GB', 'Großbritannien'], ['US', 'USA']];
/* Compose the free-text postal block (for the inline display) from the structured address —
mirrors the backend InvoiceRecipientAddress.Compose (the VAT id is never part of the block). */
$inv.composeAddress = function (a) {
a = a || {}; let lines = [];
let push = (v) => { v = ('' + (v || '')).trim(); if (v !== '') lines.push(v); };
push(a.name);
if (('' + (a.contact || '')).trim() !== '') push('z.Hd. ' + ('' + a.contact).trim());
push(a.line2 || a.addressLine2);
push(a.street);
push((('' + (a.postalCode || '')) + ' ' + ('' + (a.city || ''))).trim());
let cc = ('' + (a.countryCode || '')).toUpperCase();
if (cc !== '' && cc !== 'DE') push(cc);
return lines.join('\n');
};
/* The structured recipient address currently in effect: the backend keeps it in
CustomValues.sendToAddress (survives a draft refresh); new invoices seed it from
fds__prepInvoice's invoiceaddressData; otherwise start empty. */
$inv.adrCurrent = function (tbl) {
let cv = jObj((tbl.data('new') || {}).CustomValues, 'sendToAddress');
if (typeof cv === 'string' && cv.trim().charAt(0) === '{') { try { cv = JSON.parse(cv); } catch (e) { cv = null; } }
if (cv && typeof cv === 'object') return cv;
let ad = (tbl.data('admin') || {}).invoiceaddressData;
if (typeof ad === 'string' && ad.trim().charAt(0) === '{') { try { return JSON.parse(ad); } catch (e) { } }
else if (ad && typeof ad === 'object') { return ad; }
return {};
};
/* Structured recipient-address dialog (EN 16931 / DATEV). Posts the address delta as a JSON
object; the backend stores it, composes SendToAddress and keeps the PDF unchanged (ADR 0012).
B2C stays effortless: the USt-IdNr. is optional (empty = private person). */
$inv.eAddress = function (ev) {
let tbl = $inv.d.tbl(), cur = $inv.adrCurrent(tbl);
let flds = [
{ name: 'name', label: 'Name / Firma', type: 'text', value: cur.name || '', required: true },
{ name: 'contact', label: 'z.Hd. (optional)', type: 'text', value: cur.contact || '' },
{ name: 'street', label: 'Straße + Nr.', type: 'text', value: cur.street || '' },
{ name: 'line2', label: 'Adresszusatz (optional)', type: 'text', value: cur.line2 || cur.addressLine2 || '' },
{ name: 'postalCode', label: 'PLZ', type: 'text', value: cur.postalCode || '' },
{ name: 'city', label: 'Ort', type: 'text', value: cur.city || '' },
{ 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: 'leitwegId', label: 'Leitweg-ID (nur Behörden / B2G)', type: 'text', value: cur.leitwegId || '' }
];
let hint = $$.dc('adr-conformity').append([
$$.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 — '
+ '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, {
title: 'Rechnungsempfänger',
addcontent: hint,
success: function (res) {
let a = {
name: res.name || '', contact: res.contact || '', line2: res.line2 || '',
street: res.street || '', postalCode: res.postalCode || '', city: res.city || '',
countryCode: (res.countryCode || 'DE'), vatId: ('' + (res.vatId || '')).trim(),
leitwegId: ('' + (res.leitwegId || '')).trim()
};
let txt = $inv.composeAddress(a);
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
/* Structured delta — the invoice draft backend accepts a JSON object for 'address'. */
$inv.d.sync({ Target: 'address', Value: a });
}
});
};
/* 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) {
/* The recipient address and the service date/period are edited through structured dialogs
whenever an invoice draft is active; reminders (no invoice draft token) keep the plain
free-text editor. */
if (ev.data && !(ev.data instanceof jQuery) && $inv.d && typeof $inv.d.token === 'function' && $inv.d.token() !== '') {
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;
/* Single-line fields must stay plain text — the TinyMCE/html editor wraps the value in <p>
tags, which used to get posted and persisted verbatim (e.g. <p>18.06.2026</p> in the
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+18
View File
@@ -630,3 +630,21 @@ table.if th.keep, table.if td.keep {
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
/* Conformity hint shown inside the structured recipient-address dialog (EN 16931 / DATEV). */
.adr-conformity {
margin-top: 0.75rem;
padding: 0.6rem 0.75rem;
background: #f3f7fb;
border: 1px solid #cfe0f0;
border-radius: 4px;
font-size: 0.85rem;
line-height: 1.35;
}
.adr-conformity .hd {
font-weight: 600;
margin-bottom: 0.2rem;
}
.adr-conformity .tx {
color: #445;
}
+114
View File
@@ -1504,7 +1504,121 @@ $inv.cSt = function (data) {
}
});
};
/* Country choices for the structured recipient-address dialog (ISO 3166-1 alpha-2, BT-55). */
$inv.adrCountries = [['DE', 'Deutschland'], ['AT', 'Österreich'], ['CH', 'Schweiz'], ['FR', 'Frankreich'],
['NL', 'Niederlande'], ['BE', 'Belgien'], ['LU', 'Luxemburg'], ['IT', 'Italien'], ['ES', 'Spanien'],
['PL', 'Polen'], ['DK', 'Dänemark'], ['CZ', 'Tschechien'], ['GB', 'Großbritannien'], ['US', 'USA']];
/* Compose the free-text postal block (for the inline display) from the structured address
mirrors the backend InvoiceRecipientAddress.Compose (the VAT id is never part of the block). */
$inv.composeAddress = function (a) {
a = a || {}; let lines = [];
let push = (v) => { v = ('' + (v || '')).trim(); if (v !== '') lines.push(v); };
push(a.name);
if (('' + (a.contact || '')).trim() !== '') push('z.Hd. ' + ('' + a.contact).trim());
push(a.line2 || a.addressLine2);
push(a.street);
push((('' + (a.postalCode || '')) + ' ' + ('' + (a.city || ''))).trim());
let cc = ('' + (a.countryCode || '')).toUpperCase();
if (cc !== '' && cc !== 'DE') push(cc);
return lines.join('\n');
};
/* The structured recipient address currently in effect: the backend keeps it in
CustomValues.sendToAddress (survives a draft refresh); new invoices seed it from
fds__prepInvoice's invoiceaddressData; otherwise start empty. */
$inv.adrCurrent = function (tbl) {
let cv = jObj((tbl.data('new') || {}).CustomValues, 'sendToAddress');
if (typeof cv === 'string' && cv.trim().charAt(0) === '{') { try { cv = JSON.parse(cv); } catch (e) { cv = null; } }
if (cv && typeof cv === 'object') return cv;
let ad = (tbl.data('admin') || {}).invoiceaddressData;
if (typeof ad === 'string' && ad.trim().charAt(0) === '{') { try { return JSON.parse(ad); } catch (e) { } }
else if (ad && typeof ad === 'object') { return ad; }
return {};
};
/* Structured recipient-address dialog (EN 16931 / DATEV). Posts the address delta as a JSON
object; the backend stores it, composes SendToAddress and keeps the PDF unchanged (ADR 0012).
B2C stays effortless: the USt-IdNr. is optional (empty = private person). */
$inv.eAddress = function (ev) {
let tbl = $inv.d.tbl(), cur = $inv.adrCurrent(tbl);
let flds = [
{ name: 'name', label: 'Name / Firma', type: 'text', value: cur.name || '', required: true },
{ name: 'contact', label: 'z.Hd. (optional)', type: 'text', value: cur.contact || '' },
{ name: 'street', label: 'Straße + Nr.', type: 'text', value: cur.street || '' },
{ name: 'line2', label: 'Adresszusatz (optional)', type: 'text', value: cur.line2 || cur.addressLine2 || '' },
{ name: 'postalCode', label: 'PLZ', type: 'text', value: cur.postalCode || '' },
{ name: 'city', label: 'Ort', type: 'text', value: cur.city || '' },
{ 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: 'leitwegId', label: 'Leitweg-ID (nur Behörden / B2G)', type: 'text', value: cur.leitwegId || '' }
];
let hint = $$.dc('adr-conformity').append([
$$.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 — '
+ '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, {
title: 'Rechnungsempfänger',
addcontent: hint,
success: function (res) {
let a = {
name: res.name || '', contact: res.contact || '', line2: res.line2 || '',
street: res.street || '', postalCode: res.postalCode || '', city: res.city || '',
countryCode: (res.countryCode || 'DE'), vatId: ('' + (res.vatId || '')).trim(),
leitwegId: ('' + (res.leitwegId || '')).trim()
};
let txt = $inv.composeAddress(a);
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
/* Structured delta — the invoice draft backend accepts a JSON object for 'address'. */
$inv.d.sync({ Target: 'address', Value: a });
}
});
};
/* 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) {
/* The recipient address and the service date/period are edited through structured dialogs
whenever an invoice draft is active; reminders (no invoice draft token) keep the plain
free-text editor. */
if (ev.data && !(ev.data instanceof jQuery) && $inv.d && typeof $inv.d.token === 'function' && $inv.d.token() !== '') {
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;
/* Single-line fields must stay plain text the TinyMCE/html editor wraps the value in <p>
tags, which used to get posted and persisted verbatim (e.g. <p>18.06.2026</p> in the
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,41 @@
-- =============================================
-- Returns the recipient company's postal address as a structured JSON object
-- ({name, street, postalCode, city, state, country}) for the invoice editor's
-- structured address dialog (EN 16931 / eRechnung). Mirrors the location
-- resolution of [fds__getCompanyNameAddress] but keeps the fields separate
-- instead of composing them into one free-text string. See ADR 0012.
-- =============================================
CREATE FUNCTION [dbo].[fds__getCompanyAddressJson]
(
@companyid bigint
)
RETURNS nvarchar(max)
AS
BEGIN
DECLARE @locationid bigint, @name nvarchar(255);
DECLARE @street nvarchar(255), @postal nvarchar(255), @city nvarchar(255), @state nvarchar(255), @country varchar(15);
SELECT TOP(1) @locationid = cy.[Location#ID], @name = cy.[name]
FROM [dbo].[mfr__companies] as cy WHERE cy.[id] = @companyid;
IF @locationid IS NULL
SELECT TOP(1) @locationid = l.[ID]
FROM [dbo].[mfr__#locations] as l
JOIN [dbo].[mfr__companies] as cy ON l.[Property] = 'Company:Location' AND l.[EntityId] = cy.[Id]
WHERE cy.[id] = @companyid;
SELECT TOP(1) @street = loc.[AddressString], @postal = loc.[Postal], @city = loc.[City],
@state = loc.[State], @country = loc.[Country]
FROM [dbo].[mfr__#locations] as loc WHERE loc.[id] = @locationid;
RETURN (
SELECT
ISNULL(@name, '') AS [name]
, ISNULL(@street, '') AS [street]
, ISNULL(@postal, '') AS [postalCode]
, ISNULL(@city, '') AS [city]
, ISNULL(@state, '') AS [state]
, ISNULL(@country, '') AS [country]
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
END
@@ -17,7 +17,8 @@ CREATE PROCEDURE [dbo].[fds__createInvoice]
@ProvisionPeriod varchar(50),
@CustomValues nvarchar(max),
@authuser varchar(25),
@Id varchar(10) OUT
@Id varchar(10) OUT,
@SendToAddressJson nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
@@ -64,6 +65,7 @@ BEGIN
[IsCanceled] [bit] NULL,
[Replaces_InvId] [varchar](10) NULL,
[CustomValues] [nvarchar](max) NULL,
[SendToAddressJson] [nvarchar](max) NULL,
[DateSent] [datetime] NULL,
[UserSent] [varchar](25) NULL,
[DateFinalized] [datetime] NULL,
@@ -104,6 +106,7 @@ BEGIN
,[IsPayed]
,[IsSent]
,[CustomValues]
,[SendToAddressJson]
,[DateSent]
,[UserSent]
,[DateFinalized]
@@ -143,6 +146,7 @@ BEGIN
,0 --<IsPayed, bit,>
,0 --<IsSent, bit,>
,@CustomValues
,@SendToAddressJson
, NULL --[DateSent]
, NULL --[UserSent]
,NULL --<DateFinalized, datetime,>
@@ -45,6 +45,7 @@ BEGIN
,[IsCanceled]
,[Replaces_InvId]
,[CustomValues]
,[SendToAddressJson]
,[DateSent]
,[UserSent]
,[DateFinalized]
@@ -356,6 +356,7 @@ BEGIN
, [paymentterms] = N'10wd'
, [invoiceemail] = (SELECT TOP(1) [SupportMail] FROM @company where IsEmailInvoicingActive = 1)
, [invoiceaddress] = (SELECT TOP(1) CONCAT([name], CHAR(10), [address]) FROM @company ORDER BY IsEmailInvoicingActive DESC)
, [invoiceaddressData] = [dbo].[fds__getCompanyAddressJson]((SELECT TOP(1) [id] FROM @company ORDER BY IsEmailInvoicingActive DESC))
, [tax_servicerefund] = 0.2
, [CustomerId] = [CustomerId]
, [invoicetitle] = CASE WHEN @type = 'i' THEN (CASE WHEN @NUM_of_int_Invoices > 0 THEN CAST((@NUM_of_int_Invoices + 1) as varchar(3)) + '. ' ELSE '' END) + 'Abschlagsrechnung'
@@ -18,7 +18,8 @@ CREATE PROCEDURE [dbo].[fds__setInvoice]
@ProvisionPeriod varchar(50),
@CustomValues nvarchar(max),
@authuser varchar(25),
@Id varchar(10) OUT
@Id varchar(10) OUT,
@SendToAddressJson nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
@@ -65,6 +66,7 @@ BEGIN
[IsCanceled] [bit] NULL,
[Replaces_InvId] [varchar](50) NULL,
[CustomValues] [nvarchar](max) NULL,
[SendToAddressJson] [nvarchar](max) NULL,
[DateSent] [datetime] NULL,
[UserSent] [varchar](25) NULL,
[DateFinalized] [datetime] NULL,
@@ -102,6 +104,7 @@ BEGIN
,0 --<IsPayed, bit,>
,0 --<IsSent, bit,>
,@CustomValues
,@SendToAddressJson
, NULL --[DateSent]
, NULL --[UserSent] [varchar](25) NULL,
,NULL --<DateFinalized, datetime,>
@@ -133,6 +136,7 @@ BEGIN
,[IsPayed]
,[IsSent]
,[CustomValues]
,[SendToAddressJson]
,[DateSent]
,[UserSent]
,[DateFinalized]
@@ -163,6 +167,7 @@ BEGIN
,[ProvisionPeriod] = SOURCE.[ProvisionPeriod]
,[ProvisionLocation] = SOURCE.[ProvisionLocation]
,[CustomValues] = SOURCE.[CustomValues]
,[SendToAddressJson] = SOURCE.[SendToAddressJson]
,[DateModified] = SOURCE.[DateModified]
,[UserModified] = SOURCE.[UserModified]
OUTPUT inserted.*
@@ -25,6 +25,7 @@
[IsCanceled] AS (CONVERT([bit],case when [DateCancelled] IS NULL then (0) else (1) end)),
[Replaces_InvId] VARCHAR (50) NULL,
[CustomValues] NVARCHAR (MAX) NULL,
[SendToAddressJson] NVARCHAR (MAX) NULL,
[DateSent] DATETIME NULL,
[UserSent] VARCHAR (25) NULL,
[DateFinalized] DATETIME NULL,
@@ -25,6 +25,7 @@
[IsCanceled] BIT NULL,
[Replaces_InvId] VARCHAR (50) NULL,
[CustomValues] NVARCHAR (MAX) NULL,
[SendToAddressJson] NVARCHAR (MAX) NULL,
[DateSent] DATETIME NULL,
[UserSent] VARCHAR (25) NULL,
[DateFinalized] DATETIME NULL,