From 4abf81cd7d36b420e583a05808e2db0b3f6b52ed Mon Sep 17 00:00:00 2001 From: Stefan Date: Mon, 6 Jul 2026 00:01:35 +0200 Subject: [PATCH] Refactor stored procedure and update project structure - Modified the stored procedure `fds__admin_getReportCatalog.sql` to use the correct schema for `all_objects`. - Added new folders and projects for `eRechnungLib` in the solution file `Fuchs_Intranet.slnx`, including validation and test projects. - Updated submodule reference for `OCORE`. - Added new submodule `eRechnungLib` with initial commit. --- .github/copilot-instructions.md | 1 + .gitmodules | 3 + .vscode/settings.json | 8 + CLAUDE.md | 2 +- CODEX.md | 2 +- Fuchs.Tests/Fuchs.Tests.csproj | 1 + Fuchs.Tests/PdfPipelineTests.cs | 370 ++++++++++++++++++ Fuchs.Tests/StartupSelfTestServiceTests.cs | 308 +++++++++++++++ .../Controllers/IntranetController.Banking.cs | 2 +- .../IntranetController.Invoices.cs | 12 +- .../IntranetController.Invoices2.cs | 2 +- .../IntranetController.Reminder.cs | 8 +- .../IntranetController.Requests.cs | 24 +- Fuchs/Controllers/IntranetController.cs | 22 +- .../0005-pdf-generation-and-erechnung.md | 84 ++++ Fuchs/Program.cs | 10 + Fuchs/Services/FuchsPdfService.cs | 84 +++- Fuchs/Services/StartupSelfTestService.cs | 345 ++++++++++++++++ Fuchs/Services/StartupSelfTestSettings.cs | 47 +++ Fuchs/appsettings.Development.json | 15 +- Fuchs/appsettings.json | 15 +- Fuchs/code/FuchsPdf.cs | 246 +++++++++--- Fuchs/wwwroot/web/tools.js | 5 +- .../fds__admin_getReportCatalog.sql | 2 +- Fuchs_Intranet.slnx | 17 + OCORE | 2 +- eRechnungLib | 1 + 27 files changed, 1544 insertions(+), 94 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 Fuchs.Tests/PdfPipelineTests.cs create mode 100644 Fuchs.Tests/StartupSelfTestServiceTests.cs create mode 100644 Fuchs/Docs/Decisions/0005-pdf-generation-and-erechnung.md create mode 100644 Fuchs/Services/StartupSelfTestService.cs create mode 100644 Fuchs/Services/StartupSelfTestSettings.cs create mode 160000 eRechnungLib diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3098f14..bfc1512 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -15,6 +15,7 @@ - Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`. - Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves. - Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`. +- Submodules include the OCORE projects and `eRechnungLib` (ZUGFeRD/Factur-X + XRechnung generation) — invoices are moving to eRechnung output. - Project structure (relative to `Fuchs/`): - `Controllers/` — `IntranetController` partials (no area) - `code/` — business logic, PDF, email, widgets, data models diff --git a/.gitmodules b/.gitmodules index b5daf74..61f0707 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,6 @@ [submodule "OCORE_Charting"] path = OCORE_Charting url = https://git.processweb.de/Stefan/OCORE_Charting.git +[submodule "eRechnungLib"] + path = eRechnungLib + url = https://git.processweb.de/ProcessWeb_Tools/eRechnungLib.git diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5e4f082 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "chat.tools.terminal.autoApprove": { + "dotnet run": true, + "dotnet test": true, + "dotnet build": true, + "npx gulp": true + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index a7bbac7..c6b1216 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ ## Project Overview - **Fuchs Intranet** — ASP.NET Core (**.NET 10**) web app; the intranet IS the whole website, served from `/`. - Routes: `/{fn?}/{id?}/{code?}` → `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` → `IntranetController.Do` (dispatches by `fn` to `Do_Process_*`). -- Solution `Fuchs_Intranet.slnx`. Key projects: `Fuchs` (web), `Fuchs_DataService` (MFR sync worker), `MFR_RESTClient`, `CAMTParser`, `Fuchs.Tests`, and the OCORE submodules (`OCORE`, `OCORE_web`, `OCORE_web_pdf`, `OCORE_Charting`). `MT940Parser` is an external referenced project. +- Solution `Fuchs_Intranet.slnx`. Key projects: `Fuchs` (web), `Fuchs_DataService` (MFR sync worker), `MFR_RESTClient`, `CAMTParser`, `Fuchs.Tests`, and the OCORE submodules (`OCORE`, `OCORE_web`, `OCORE_web_pdf`, `OCORE_Charting`). `eRechnungLib` is a submodule (ZUGFeRD/Factur-X + XRechnung generation) — invoices are moving to eRechnung output. `MT940Parser` is an external referenced project. ## Build & Test (workflow) - Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`. diff --git a/CODEX.md b/CODEX.md index e83a569..9058e9c 100644 --- a/CODEX.md +++ b/CODEX.md @@ -11,7 +11,7 @@ ## Project Overview - **Fuchs Intranet** — ASP.NET Core (**.NET 10**) web app; the intranet IS the whole website, served from `/`. - Routes: `/{fn?}/{id?}/{code?}` -> `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` -> `IntranetController.Do` (dispatches by `fn` to `Do_Process_*`). -- Solution `Fuchs_Intranet.slnx`. Key projects: `Fuchs` (web), `Fuchs_DataService` (MFR sync worker), `MFR_RESTClient`, `CAMTParser`, `Fuchs.Tests`, and the OCORE submodules (`OCORE`, `OCORE_web`, `OCORE_web_pdf`, `OCORE_Charting`). `MT940Parser` is an external referenced project. +- Solution `Fuchs_Intranet.slnx`. Key projects: `Fuchs` (web), `Fuchs_DataService` (MFR sync worker), `MFR_RESTClient`, `CAMTParser`, `Fuchs.Tests`, and the OCORE submodules (`OCORE`, `OCORE_web`, `OCORE_web_pdf`, `OCORE_Charting`). `eRechnungLib` is a submodule (ZUGFeRD/Factur-X + XRechnung generation) — invoices are moving to eRechnung output. `MT940Parser` is an external referenced project. ## Build & Test (workflow) - Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`. diff --git a/Fuchs.Tests/Fuchs.Tests.csproj b/Fuchs.Tests/Fuchs.Tests.csproj index 3cfb7ed..1e28bd3 100644 --- a/Fuchs.Tests/Fuchs.Tests.csproj +++ b/Fuchs.Tests/Fuchs.Tests.csproj @@ -28,6 +28,7 @@ + diff --git a/Fuchs.Tests/PdfPipelineTests.cs b/Fuchs.Tests/PdfPipelineTests.cs new file mode 100644 index 0000000..88f7772 --- /dev/null +++ b/Fuchs.Tests/PdfPipelineTests.cs @@ -0,0 +1,370 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using eRechnungLib; +using eRechnungLib.Model; +using eRechnungLib.Model.CodeLists; +using eRechnungLib.Profiles; +using Fuchs.intranet; +using Fuchs.Services; +using MigraDoc.DocumentObjectModel; +using MigraDoc.Rendering; +using Microsoft.Extensions.Configuration; +using PdfSharp.Pdf.IO; +using Xunit; + +namespace Fuchs.Tests; + +/// +/// Exercises the invoice PDF pipeline end to end: +/// 1. a visual invoice PDF is produced with PdfSharp/MigraDoc, +/// 2. it is rasterised to preview images via Spire (the licensed path used by sprep), +/// 3. it is turned into a formally valid eRechnung (ZUGFeRD/Factur-X hybrid + XRechnung XML) +/// via eRechnungLib — the direction the project is moving in (all invoices as eRechnung). +/// Both an intentionally succeeding and an intentionally failing conversion path are covered. +/// +public class PdfPipelineTests +{ + // ── Stage 1 helper: a "dummy" visual invoice PDF built purely with PdfSharp/MigraDoc ── + private static byte[] BuildDummyPdfWithPdfSharp() + { + // Same font resolver the production render path installs (PdfSharp 6 no longer + // resolves system fonts on its own). + if (PdfSharp.Fonts.GlobalFontSettings.FontResolver is null || + PdfSharp.Fonts.GlobalFontSettings.FontResolver.GetType() != typeof(OCORE_web_pdf.pdf.OCOREFontResolver)) + { + PdfSharp.Fonts.GlobalFontSettings.FontResolver = new OCORE_web_pdf.pdf.OCOREFontResolver(); + } + + var doc = new Document(); + doc.Info.Title = "Dummy Rechnung"; + var normal = doc.Styles["Normal"]!; + normal.Font.Name = "Arial"; + var sec = doc.AddSection(); + var title = sec.AddParagraph("Rechnung Nr. RE-2026-0001"); + title.Format.Font.Size = 14; + title.Format.Font.Bold = true; + sec.AddParagraph("Position 1: Beratungsleistung — 200,00 EUR netto"); + sec.AddParagraph("Position 2: Entwicklung — 500,00 EUR netto"); + + var renderer = new PdfDocumentRenderer { Document = doc }; + renderer.RenderDocument(); + using var ms = new MemoryStream(); + renderer.PdfDocument.Save(ms, closeStream: false); + return ms.ToArray(); + } + + // ── Stage 3 helper: a minimal but EN 16931-complete domestic invoice model ── + private static Invoice BuildValidInvoice(string number = "RE-2026-0001") + { + var seller = new TradeParty + { + Name = "Sebastian Fuchs Bad und Heizung GmbH & Co. KG", + Address = new PostalAddress + { + Line1 = "Germaniastraße 15", + City = "Düsseldorf", + PostalCode = "40223", + Country = CountryCode.Germany, + }, + Contact = new TradeContact { Name = "Sebastian Fuchs", Email = "info@sanitaerfuchs.de", Telephone = "0211 3107222" }, + ElectronicAddress = new Identifier("DE286366012", "0204"), + }; + seller.TaxRegistrations.Add(new TaxRegistration("DE286366012", TaxRegistrationScheme.Vat)); + + var buyer = new TradeParty + { + Name = "Beispiel Kunde AG", + Address = new PostalAddress + { + Line1 = "Kundenweg 2", + City = "München", + PostalCode = "80331", + Country = CountryCode.Germany, + }, + }; + + var invoice = new Invoice + { + InvoiceNumber = number, + IssueDate = new DateOnly(2026, 6, 1), + CurrencyCode = CurrencyCode.Eur, + BuyerReference = "04011000-12345-34", + Seller = seller, + Buyer = buyer, + Payment = new PaymentInstructions + { + MeansCode = PaymentMeansCode.SepaCreditTransfer, + RemittanceInformation = number, + }, + PaymentTerms = new PaymentTerms + { + Description = "Zahlbar innerhalb von 14 Tagen netto.", + DueDate = new DateOnly(2026, 6, 15), + }, + }; + invoice.Payment.CreditTransfers.Add(new CreditTransferAccount + { + AccountId = "DE52301502000002091478", + AccountName = seller.Name, + BankId = "WELADED1KSD", + }); + invoice.Lines.Add(new InvoiceLine + { + Id = "1", + Quantity = 1m, + UnitCode = UnitCode.One, + NetPrice = 200m, + VatCategory = VatCategoryCode.StandardRate, + VatRate = 19m, + Item = new TradeItem { Name = "Beratungsleistung", Description = "Beratung nach Aufwand" }, + }); + invoice.Lines.Add(new InvoiceLine + { + Id = "2", + Quantity = 1m, + UnitCode = UnitCode.One, + NetPrice = 500m, + VatCategory = VatCategoryCode.StandardRate, + VatRate = 19m, + Item = new TradeItem { Name = "Entwicklung", SellerItemId = "DEV-01" }, + }); + + InvoiceCalculator.Recalculate(invoice); + return invoice; + } + + // ── Stage 1 ─────────────────────────────────────────────────────────────── + [Fact] + public void Stage1_PdfSharp_produces_a_valid_pdf() + { + byte[] pdf = BuildDummyPdfWithPdfSharp(); + + Assert.True(pdf.Length > 1000); + Assert.StartsWith("%PDF", Encoding.ASCII.GetString(pdf, 0, 4)); + } + + // ── Stage 2 (Spire rasterisation — the sprep preview path) ───────────────── + [Fact] + public async Task Stage2_Spire_rasterises_the_pdf_to_preview_images() + { + FuchsPdf.SetLicense(); + byte[] pdf = BuildDummyPdfWithPdfSharp(); + + var images = await FuchsPdf.BytesToImageCollection(pdf); + + Assert.True(images.TotalPages >= 1); + Assert.NotEmpty(images.ImgB64Array); + Assert.All(images.ImgB64Array, b64 => Assert.False(string.IsNullOrWhiteSpace(b64))); + } + + // ── Stage 3 (eRechnung XML) ──────────────────────────────────────────────── + [Theory] + [InlineData(XRechnungSyntax.Ubl)] + [InlineData(XRechnungSyntax.Cii)] + public void Stage3_eRechnung_XRechnung_is_valid(XRechnungSyntax syntax) + { + var result = EInvoice.CreateInvoice(BuildValidInvoice()) + .ToXRechnung(syntax, XRechnungVersion.V4_0); + + Assert.True(result.Success); + Assert.True(result.Validation.IsValid, result.Validation.ToString()); + Assert.Contains("RE-2026-0001", Encoding.UTF8.GetString(result.Value!)); + } + + // ── Stage 3 (ZUGFeRD hybrid embedded into the PdfSharp visual PDF) ───────── + [Fact] + public void Stage3_eRechnung_Zugferd_embeds_xml_into_supplied_visual_pdf() + { + byte[] visualPdf = BuildDummyPdfWithPdfSharp(); + + var result = EInvoice.CreateInvoice(BuildValidInvoice()) + .ToZugferd(ZugferdProfile.EN16931, visualPdf); + + Assert.True(result.Success); + Assert.StartsWith("%PDF", Encoding.ASCII.GetString(result.Value!, 0, 4)); + + using var ms = new MemoryStream(result.Value!); + var pdfDoc = PdfReader.Open(ms, PdfDocumentOpenMode.Import); + // Factur-X associated-file array must be present on the catalog. + Assert.NotNull(pdfDoc.Internals.Catalog.Elements.GetArray("/AF")); + } + + // ── Full chain: PdfSharp → Spire images → eRechnung hybrid ───────────────── + [Fact] + public async Task FullChain_pdfsharp_spire_eRechnung() + { + FuchsPdf.SetLicense(); + + // 1. Visual PDF via PdfSharp. + byte[] visualPdf = BuildDummyPdfWithPdfSharp(); + Assert.StartsWith("%PDF", Encoding.ASCII.GetString(visualPdf, 0, 4)); + + // 2. Preview images via Spire. + var images = await FuchsPdf.BytesToImageCollection(visualPdf); + Assert.True(images.TotalPages >= 1); + Assert.NotEmpty(images.ImgB64Array); + + // 3. eRechnung (ZUGFeRD/Factur-X) embedding the CII XML into the visual PDF. + var hybrid = EInvoice.CreateInvoice(BuildValidInvoice()).ToZugferd(ZugferdProfile.EN16931, visualPdf); + Assert.True(hybrid.Success); + Assert.True(hybrid.Validation.IsValid, hybrid.Validation.ToString()); + + using var ms = new MemoryStream(hybrid.Value!); + var pdfDoc = PdfReader.Open(ms, PdfDocumentOpenMode.Import); + var names = pdfDoc.Internals.Catalog.Elements.GetDictionary("/Names"); + var embeddedFiles = names!.Elements.GetDictionary("/EmbeddedFiles"); + var nameArray = embeddedFiles!.Elements.GetArray("/Names"); + Assert.Contains(nameArray!.Elements, e => e.ToString()!.Contains("factur-x.xml")); + } + + // ── Intentionally failing conversion path ────────────────────────────────── + [Fact] + public void eRechnung_strict_validation_withholds_output_on_invalid_invoice() + { + var invoice = BuildValidInvoice("RE-2026-0009"); + invoice.BuyerReference = null; // violates BR-DE-15 for XRechnung + + var result = EInvoice.CreateInvoice(invoice) + .ToXRechnung(XRechnungSyntax.Ubl, XRechnungVersion.V4_0, + new ConversionOptions { StrictValidation = true }); + + Assert.False(result.Success); + Assert.Null(result.Value); + Assert.Contains(result.Validation.Errors, m => m.RuleId == "BR-DE-15"); + } + + // ── Spire license selection (managed secret vs embedded fallback) ────────── + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ResolveLicenseKey_falls_back_to_embedded_when_secret_absent(string? provided) + { + string key = FuchsPdf.ResolveLicenseKey(provided); + + Assert.False(string.IsNullOrWhiteSpace(key)); + Assert.True(key.Length > 100); // the embedded key, not the (empty) input + } + + [Fact] + public void ResolveLicenseKey_uses_managed_secret_when_present() + { + const string secret = "MANAGED-SECRET-LICENSE-VALUE"; + + Assert.Equal(secret, FuchsPdf.ResolveLicenseKey(secret)); + } + + [Fact] + public void LicenseConfigKey_matches_the_managed_secret_name_in_appsettings() + { + // The Key Vault secret is "fuchs--SpirePdf-License"; the secret-management layer strips + // the app prefix, splits "--" into ":" and maps "-" to "_" per segment. So the managed + // key "SpirePdf-License" must surface under the config key the service reads. + const string managedSecretName = "SpirePdf-License"; + string expectedConfigKey = string.Join(':', + managedSecretName.Split("--").Select(seg => seg.Replace("-", "_"))); + + Assert.Equal(FuchsPdfService.LicenseConfigKey, expectedConfigKey); + + // And that managed secret is actually registered in the real appsettings.json. + var config = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false) + .Build(); + var managedKeys = config.GetSection("SecretManagement:ManagedSecretKeys").Get() ?? []; + Assert.Contains(managedSecretName, managedKeys); + } + + [Theory] + [InlineData("SpirePdf_License")] // managed-secret mapping (fuchs--SpirePdf-License → '-' to '_') + [InlineData("SpirePdf-License")] // verbatim, e.g. appsettings.Development.json + [InlineData("SpirePdf:License")] // ':'-hierarchy variant + [InlineData("fuchs:SpirePdf-License")] + public void ResolveLicenseFromConfiguration_FindsLicenseUnderEachKnownKeyVariant(string key) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { [key] = "the-license-value" }) + .Build(); + + string? value = FuchsPdfService.ResolveLicenseFromConfiguration(config, out string? matchedKey); + + Assert.Equal("the-license-value", value); + Assert.Equal(key, matchedKey); + } + + [Fact] + public void ResolveLicenseFromConfiguration_ReturnsNull_WhenNoCandidateHasAValue() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["SpirePdf_License"] = " ", // whitespace-only is treated as absent + ["Unrelated:Key"] = "x" + }) + .Build(); + + string? value = FuchsPdfService.ResolveLicenseFromConfiguration(config, out string? matchedKey); + + Assert.Null(value); + Assert.Null(matchedKey); + } + + [Fact] + public void ResolveLicenseFromConfiguration_ReturnsNull_WhenValueIsUnloadedManagedSecretPlaceholder() + { + // appsettings.json ships "SpirePdf_License": "MANAGED_BY_KEYVAULT" so the key always + // exists; until Key Vault/cache overrides it, the literal must be treated as "no license" + // so the embedded fallback is used rather than applying the placeholder as a bogus key. + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [FuchsPdfService.LicenseConfigKey] = FuchsPdfService.UnloadedSecretPlaceholder + }) + .Build(); + + string? value = FuchsPdfService.ResolveLicenseFromConfiguration(config, out string? matchedKey); + + Assert.Null(value); + Assert.Null(matchedKey); + } + + [Fact] + public void ResolveLicenseFromConfiguration_SkipsPlaceholder_AndReturnsRealValueFromAnotherCandidate() + { + // The canonical key still carries the unresolved placeholder while a real license was + // supplied verbatim (e.g. appsettings.Development.json) under a different candidate key. + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [FuchsPdfService.LicenseConfigKey] = FuchsPdfService.UnloadedSecretPlaceholder, + ["SpirePdf-License"] = "the-real-license" + }) + .Build(); + + string? value = FuchsPdfService.ResolveLicenseFromConfiguration(config, out string? matchedKey); + + Assert.Equal("the-real-license", value); + Assert.Equal("SpirePdf-License", matchedKey); + } + + [Fact] + public void SpireLikeConfigKeys_ReportsSpireRelatedKeysForDiagnostics() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["fuchs:SpirePdf-License"] = "value", + ["Other:Setting"] = "value" + }) + .Build(); + + var keys = FuchsPdfService.SpireLikeConfigKeys(config).ToArray(); + + Assert.Contains("fuchs:SpirePdf-License", keys); + Assert.DoesNotContain("Other:Setting", keys); + } +} diff --git a/Fuchs.Tests/StartupSelfTestServiceTests.cs b/Fuchs.Tests/StartupSelfTestServiceTests.cs new file mode 100644 index 0000000..ff0199a --- /dev/null +++ b/Fuchs.Tests/StartupSelfTestServiceTests.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Fuchs.intranet; +using Fuchs.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Fuchs.Tests; + +public class StartupSelfTestServiceTests +{ + private static StartupSelfTestSettings CreateSettings( + bool enabled, + bool checkKeyVault = true, + bool checkDatabase = true, + bool checkMfr = true, + bool sendStartupEmail = false, + string startupRecipient = "", + bool checkPdfLicense = false) => new() + { + Enabled = enabled, + CheckKeyVault = checkKeyVault, + CheckDatabase = checkDatabase, + CheckMfr = checkMfr, + CheckPdfLicense = checkPdfLicense, + SendStartupEmail = sendStartupEmail, + StartupEmailRecipient = startupRecipient, + StartupEmailRecipientName = "Monitor" + }; + + private static IConfiguration CreateConfiguration() => + new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["SecretManagement:AppName"] = "fuchs", + ["SecretManagement:ManagedSecretKeys:0"] = "Fuchs--Mailer--Token" + }).Build(); + + [Fact] + public async Task ExecuteAsync_Disabled_DoesNotRunAnyChecks() + { + using var service = new TestableStartupSelfTestService( + new ServiceCollection().BuildServiceProvider(), + CreateConfiguration(), + Options.Create(CreateSettings(enabled: false)), + NullLogger.Instance) + { + KeyVaultResult = true, + DatabaseResult = true, + MfrResult = true, + MailerResult = true + }; + + await service.RunForTestAsync(CancellationToken.None); + + Assert.Equal(0, service.KeyVaultCalls); + Assert.Equal(0, service.DatabaseCalls); + Assert.Equal(0, service.MfrCalls); + Assert.Equal(0, service.MailerCalls); + } + + [Fact] + public async Task ExecuteAsync_EnabledWithAllChecks_CallsAllProbes() + { + using var service = new TestableStartupSelfTestService( + new ServiceCollection().BuildServiceProvider(), + CreateConfiguration(), + Options.Create(CreateSettings(enabled: true, checkKeyVault: true, checkDatabase: true, checkMfr: true, sendStartupEmail: true, startupRecipient: "ops@example.test", checkPdfLicense: true)), + NullLogger.Instance) + { + KeyVaultResult = true, + DatabaseResult = true, + MfrResult = true, + MailerResult = true, + PdfLicenseResult = true + }; + + await service.RunForTestAsync(CancellationToken.None); + + Assert.Equal(1, service.KeyVaultCalls); + Assert.Equal(1, service.DatabaseCalls); + Assert.Equal(1, service.MfrCalls); + Assert.Equal(1, service.MailerCalls); + Assert.Equal(1, service.PdfLicenseCalls); + } + + [Fact] + public async Task ExecuteAsync_PdfLicenseCheckDisabled_DoesNotProbePdfLicense() + { + using var service = new TestableStartupSelfTestService( + new ServiceCollection().BuildServiceProvider(), + CreateConfiguration(), + Options.Create(CreateSettings(enabled: true, checkKeyVault: false, checkDatabase: false, checkMfr: false, checkPdfLicense: false)), + NullLogger.Instance) + { + PdfLicenseResult = true + }; + + await service.RunForTestAsync(CancellationToken.None); + + Assert.Equal(0, service.PdfLicenseCalls); + } + + [Fact] + public async Task ProbePdfLicenseAsync_MissingLicenseString_ReturnsFalse() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()) + .Build(); + using var service = new ProbeExposingStartupSelfTestService( + new ServiceCollection().BuildServiceProvider(), + config, + Options.Create(CreateSettings(enabled: true, checkPdfLicense: true)), + NullLogger.Instance); + + bool ok = await service.InvokeProbePdfLicenseAsync(CancellationToken.None); + + Assert.False(ok); + } + + [Fact] + public async Task ProbePdfLicenseAsync_EmptyLicenseString_ReturnsFalse() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [FuchsPdfService.LicenseConfigKey] = " " + }) + .Build(); + using var service = new ProbeExposingStartupSelfTestService( + new ServiceCollection().BuildServiceProvider(), + config, + Options.Create(CreateSettings(enabled: true, checkPdfLicense: true)), + NullLogger.Instance); + + bool ok = await service.InvokeProbePdfLicenseAsync(CancellationToken.None); + + Assert.False(ok); + } + + [Fact] + public async Task ProbePdfLicenseAsync_InvalidLicenseString_ReportsUnlicensed() + { + // A syntactically-present but invalid key leaves Spire.PDF in evaluation mode, which the + // probe must detect (the evaluation watermark appears on the rendered document). + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [FuchsPdfService.LicenseConfigKey] = "not-a-valid-spire-license-key" + }) + .Build(); + using var service = new ProbeExposingStartupSelfTestService( + new ServiceCollection().BuildServiceProvider(), + config, + Options.Create(CreateSettings(enabled: true, checkPdfLicense: true)), + NullLogger.Instance); + + try + { + bool ok = await service.InvokeProbePdfLicenseAsync(CancellationToken.None); + Assert.False(ok); + } + finally + { + // Restore the embedded (valid-format) key so other Spire-using tests aren't left + // with a malformed license that makes Spire throw on save. + FuchsPdf.SetLicense(); + } + } + + [Fact] + public void SpirePdfIsLicensed_InEvaluationMode_ReturnsFalse() + { + // The embedded fallback key does not license current Spire.PDF, so Spire runs as the + // evaluation edition and stamps a watermark, which the detection reports as unlicensed. + FuchsPdf.SetLicense(); + + Assert.False(StartupSelfTestService.SpirePdfIsLicensed()); + } + + [Fact] + public async Task ExecuteAsync_EnabledWithMailerOnly_CallsOnlyMailerCheck() + { + using var service = new TestableStartupSelfTestService( + new ServiceCollection().BuildServiceProvider(), + CreateConfiguration(), + Options.Create(CreateSettings(enabled: true, checkKeyVault: false, checkDatabase: false, checkMfr: false, sendStartupEmail: true, startupRecipient: "ops@example.test")), + NullLogger.Instance) + { + KeyVaultResult = true, + DatabaseResult = true, + MfrResult = true, + MailerResult = false + }; + + await service.RunForTestAsync(CancellationToken.None); + + Assert.Equal(0, service.KeyVaultCalls); + Assert.Equal(0, service.DatabaseCalls); + Assert.Equal(0, service.MfrCalls); + Assert.Equal(1, service.MailerCalls); + } + + [Fact] + public async Task ExecuteAsync_ProbeThrows_ServiceDoesNotThrowAndContinuesRemainingChecks() + { + using var service = new TestableStartupSelfTestService( + new ServiceCollection().BuildServiceProvider(), + CreateConfiguration(), + Options.Create(CreateSettings(enabled: true, checkKeyVault: true, checkDatabase: true, checkMfr: true, sendStartupEmail: true, startupRecipient: "ops@example.test")), + NullLogger.Instance) + { + KeyVaultException = new InvalidOperationException("probe failed"), + DatabaseResult = true, + MfrResult = true, + MailerResult = true + }; + + var exception = await Record.ExceptionAsync(async () => + { + await service.RunForTestAsync(CancellationToken.None); + }); + + Assert.Null(exception); + Assert.Equal(1, service.KeyVaultCalls); + Assert.Equal(1, service.DatabaseCalls); + Assert.Equal(1, service.MfrCalls); + Assert.Equal(1, service.MailerCalls); + } + + private sealed class TestableStartupSelfTestService : StartupSelfTestService + { + public int KeyVaultCalls { get; private set; } + public int DatabaseCalls { get; private set; } + public int MfrCalls { get; private set; } + public int MailerCalls { get; private set; } + public int PdfLicenseCalls { get; private set; } + public bool KeyVaultResult { get; set; } + public bool DatabaseResult { get; set; } + public bool MfrResult { get; set; } + public bool MailerResult { get; set; } + public bool PdfLicenseResult { get; set; } + public Exception? KeyVaultException { get; set; } + + public TestableStartupSelfTestService( + IServiceProvider serviceProvider, + IConfiguration configuration, + IOptions settings, + Microsoft.Extensions.Logging.ILogger logger) + : base(serviceProvider, configuration, settings, logger) + { + } + + protected override Task ProbeKeyVaultAsync(CancellationToken cancellationToken) + { + KeyVaultCalls++; + if (KeyVaultException is not null) throw KeyVaultException; + return Task.FromResult(KeyVaultResult); + } + + protected override Task SendStartupEmailAsync(CancellationToken cancellationToken) + { + MailerCalls++; + return Task.FromResult(MailerResult); + } + + protected override Task ProbeDatabaseAsync(CancellationToken cancellationToken) + { + DatabaseCalls++; + return Task.FromResult(DatabaseResult); + } + + protected override Task ProbeMfrAsync(CancellationToken cancellationToken) + { + MfrCalls++; + return Task.FromResult(MfrResult); + } + + protected override Task ProbePdfLicenseAsync(CancellationToken cancellationToken) + { + PdfLicenseCalls++; + return Task.FromResult(PdfLicenseResult); + } + + public Task RunForTestAsync(CancellationToken cancellationToken) + => RunOnceAsync(cancellationToken); + } + + /// Exposes the real (non-overridden) PDF license probe for direct testing. + private sealed class ProbeExposingStartupSelfTestService : StartupSelfTestService + { + public ProbeExposingStartupSelfTestService( + IServiceProvider serviceProvider, + IConfiguration configuration, + IOptions settings, + Microsoft.Extensions.Logging.ILogger logger) + : base(serviceProvider, configuration, settings, logger) + { + } + + public Task InvokeProbePdfLicenseAsync(CancellationToken cancellationToken) + => ProbePdfLicenseAsync(cancellationToken); + } +} diff --git a/Fuchs/Controllers/IntranetController.Banking.cs b/Fuchs/Controllers/IntranetController.Banking.cs index 6c69d04..efbb431 100644 --- a/Fuchs/Controllers/IntranetController.Banking.cs +++ b/Fuchs/Controllers/IntranetController.Banking.cs @@ -235,7 +235,7 @@ public partial class IntranetController } default: - return Ok(); + return await JSONAsync(new { ok = true }); } } diff --git a/Fuchs/Controllers/IntranetController.Invoices.cs b/Fuchs/Controllers/IntranetController.Invoices.cs index a4ccf3e..cbc0dbb 100644 --- a/Fuchs/Controllers/IntranetController.Invoices.cs +++ b/Fuchs/Controllers/IntranetController.Invoices.cs @@ -36,7 +36,7 @@ public partial class IntranetController StdParamlist(SQL_VarChar("@Id", invoiceId)), Security: DbSec, options: SqlOpt(fn, id, code)); if (!ok) _logger.LogError("setpyd: SQL failed for invoice {InvoiceId}, user={User}", invoiceId, UserAccountID); - return ok ? Ok() : StatusCode(500); + return ok ? await JSONAsync(new { ok = true }) : StatusCode(500); } case "setupd": @@ -50,7 +50,7 @@ public partial class IntranetController StdParamlist(SQL_VarChar("@Id", invoiceId)), Security: DbSec, options: SqlOpt(fn, id, code)); if (!ok) _logger.LogError("setupd: SQL failed for invoice {InvoiceId}, user={User}", invoiceId, UserAccountID); - return ok ? Ok() : StatusCode(500); + return ok ? await JSONAsync(new { ok = true }) : StatusCode(500); } case "setvat": @@ -72,7 +72,7 @@ public partial class IntranetController _intranet.Intranet_SqlCon(), ref sqlEx, ref sqlCode, pl, Security: DbSec); if (!string.IsNullOrEmpty(sqlEx)) _logger.LogError("setvat: SQL error for report {ReportId}: {SqlError}, user={User}", Form("id"), sqlEx, UserAccountID); - return string.IsNullOrEmpty(sqlEx) ? Ok() : StatusCode(500, new { error = sqlEx }); + return string.IsNullOrEmpty(sqlEx) ? await JSONAsync(new { ok = true }) : StatusCode(500, new { error = sqlEx }); } case "sis": @@ -94,7 +94,7 @@ public partial class IntranetController } else await _events.InvoiceMarkedSentAsync(invoiceId, invoiceId, UserAccountID); - return string.IsNullOrEmpty(dt2.Exception) ? Ok() : StatusCode(500); + return string.IsNullOrEmpty(dt2.Exception) ? await JSONAsync(new { ok = true }) : StatusCode(500); } case "pget": @@ -156,11 +156,11 @@ public partial class IntranetController using (var mfr = _mfrFactory.Create()) await mfr.Update__entitytable(EntityTypes.Invoice, fds.FdsMfr.UpdateNeed.Reset, new[] { relId }); - return Ok(); + return await JSONAsync(new { ok = true }); default: _logger.LogWarning("Do_Process_Invoices: unhandled action id={Id}, user={User}", id, UserAccountID); - return Ok(); + return await JSONAsync(new { ok = true }); } } } diff --git a/Fuchs/Controllers/IntranetController.Invoices2.cs b/Fuchs/Controllers/IntranetController.Invoices2.cs index fbc1019..319dcfc 100644 --- a/Fuchs/Controllers/IntranetController.Invoices2.cs +++ b/Fuchs/Controllers/IntranetController.Invoices2.cs @@ -58,7 +58,7 @@ public partial class IntranetController _logger.LogInformation("HandleInvoicePget reset complete for tgtid={TgtId} invoices={InvCount} serviceRequests={SrqCount} user={User}", tgtid, invIds.Count, srqIds.Count, UserAccountID); } - return Ok(); + return await JSONAsync(new { ok = true }); } private async Task HandleInvoiceGet(string fn, string id, string code) diff --git a/Fuchs/Controllers/IntranetController.Reminder.cs b/Fuchs/Controllers/IntranetController.Reminder.cs index 45b42d8..3c57213 100644 --- a/Fuchs/Controllers/IntranetController.Reminder.cs +++ b/Fuchs/Controllers/IntranetController.Reminder.cs @@ -66,7 +66,7 @@ public partial class IntranetController await _events.ReminderIssueAsync( $"Mahnung {Form("id")} konnte nicht als versandt markiert werden.", UserAccountID, Form("id")); - return string.IsNullOrEmpty(dt2.Exception) ? Ok() : StatusCode(500); + return string.IsNullOrEmpty(dt2.Exception) ? await JSONAsync(new { ok = true }) : StatusCode(500); } case "rdoc": @@ -98,7 +98,7 @@ public partial class IntranetController }); } - default: return Ok(); + default: return await JSONAsync(new { ok = true }); } } @@ -160,7 +160,7 @@ public partial class IntranetController $"Die Mahn-PDF {frdic.nz("DocumentName", "").ne($"Zahlungserinnerung_{remId}.pdf")} konnte nicht erstellt werden.", UserAccountID, remId); } - return Ok(); + return await JSONAsync(new { ok = true }); } return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht erstellt werden."); } @@ -223,7 +223,7 @@ public partial class IntranetController UserAccountID, remId); } } - return Ok(); + return await JSONAsync(new { ok = true }); } return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht versandt werden."); } diff --git a/Fuchs/Controllers/IntranetController.Requests.cs b/Fuchs/Controllers/IntranetController.Requests.cs index 26d818d..2ec8e1f 100644 --- a/Fuchs/Controllers/IntranetController.Requests.cs +++ b/Fuchs/Controllers/IntranetController.Requests.cs @@ -92,13 +92,13 @@ public partial class IntranetController _intranet.Intranet__SQLConnectionString, StdParamlist(SQL_VarChar("@Id", Form("id"))), Security: DbSec, options: SqlOpt(fn, id, code)); - return Ok(); + return await JSONAsync(new { ok = true }); case "sconf": return await HandleRequestSconf(fn, id, code); case "idoc": return await HandleRequestIdoc(fn, id, code); case "resend": return await HandleRequestResend(fn, id, code); - default: return Ok(); + default: return await JSONAsync(new { ok = true }); } } @@ -165,7 +165,13 @@ public partial class IntranetController private async Task HandleRequestPget(string fn, string id, string code) { - if (!HasForm("id") || !long.TryParse(Form("id"), out long tgtid)) return BadRequest400(); + if (!HasForm("id") || !long.TryParse(Form("id"), out long tgtid)) + { + _logger.LogWarning("HandleRequestPget: missing/invalid 'id' value='{Value}' user={User}", Form("id"), UserAccountID); + return BadRequest400(); + } + _logger.LogDebug("HandleRequestPget tgtid={TgtId} user={User}", tgtid, UserAccountID); + var dt = await getSQLDatatable_async( "SELECT * FROM [dbo].[fds__getRequestTreeIds](@srqid);", _intranet.Intranet__SQLConnectionString, @@ -181,15 +187,19 @@ public partial class IntranetController if (iid > 0 && !ids.Contains(iid)) ids.Add(iid); } } + _logger.LogDebug("HandleRequestPget tgtid={TgtId} resolved {Count} related ids: {Ids}", tgtid, ids.Count, string.Join(",", ids)); + var schemaDic = new Dictionary { [EntityHelper.EntityName(EntityTypes.ServiceRequest)] = new fds.FdsMfrClient.DatabaseSchema(EntityTypes.ServiceRequest) }; using var mfr = _mfrFactory.Create(); - await mfr.Update__entitytable(EntityTypes.ServiceRequest, + bool ok = await mfr.Update__entitytable(EntityTypes.ServiceRequest, fds.FdsMfr.UpdateNeed.Reset, ids.ToArray(), schemaDic: schemaDic); - return Ok(); + _logger.LogInformation("HandleRequestPget MFR update complete tgtid={TgtId} ids={Count} success={Success} user={User}", + tgtid, ids.Count, ok, UserAccountID); + return await JSONAsync(new { ok }); } private async Task HandleRequestGet(string fn, string id, string code) @@ -321,7 +331,7 @@ public partial class IntranetController $"Die Rechnungs-PDF {frdic.nz("DocumentName").ne($"Rechnung_{invId}.pdf")} konnte nicht erstellt werden.", UserAccountID, invId); } - return Ok(); + return await JSONAsync(new { ok = true }); } return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden."); } @@ -381,7 +391,7 @@ public partial class IntranetController UserAccountID, invId); } } - return Ok(); + return await JSONAsync(new { ok = true }); } return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht versandt werden."); } diff --git a/Fuchs/Controllers/IntranetController.cs b/Fuchs/Controllers/IntranetController.cs index c516489..2342e6a 100644 --- a/Fuchs/Controllers/IntranetController.cs +++ b/Fuchs/Controllers/IntranetController.cs @@ -144,7 +144,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller { IActionResult? result = fn.ToLower() switch { - "ping" => Ok(), + "ping" => await JSONAsync(new { ok = true }), "wdg" => await _widgets.GetWidgetAsync(id, UserAccountID, DbSec, Request), "todos" => new PhysicalFileResult( Path.Combine(Directory.GetCurrentDirectory(), "Data", "ProjectToDos.html"), @@ -168,7 +168,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller _logger.LogWarning("No handler matched fn={Fn}", fn); else _logger.LogDebug("Do completed fn={Fn}/{Id} result={ResultType}", fn, id, result.GetType().Name); - return result ?? Ok(); + return result ?? await JSONAsync(new { ok = true }); } catch (Exception ex) { @@ -255,7 +255,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller UserAccountID, HttpContext.Connection.RemoteIpAddress); await HttpContext.SignOutAsync(Fuchs_intranet.AuthScheme); _logger.LogDebug("Logout sign-out complete for user={User}", UserAccountID); - return Ok(); + return await JSONAsync(new { ok = true }); } // ── Password helpers ────────────────────────────────────────────────────── @@ -285,7 +285,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller { _logger.LogDebug("HandleSendPasswordCode: no SMS sent for email={Email} (user not found, name mismatch, no mobile, or localhost)", email); } - return Ok(); // always OK to prevent enumeration + return await JSONAsync(new { ok = true }); // always OK to prevent enumeration } private async Task HandleSendPassword(string fn, string id, string code) @@ -323,7 +323,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller { _logger.LogWarning("HandleSendPassword: TOTP verification failed for email={Email}", email); } - return Ok(); + return await JSONAsync(new { ok = true }); } private async Task HandleAccount(string fn, string id, string code) @@ -345,7 +345,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller { _logger.LogDebug("HandleAccount sms: no SMS sent for user={User} (no mobile or localhost)", UserAccountID); } - return Ok(); + return await JSONAsync(new { ok = true }); case "changepassword": string? npw = Request.Form["npw"]; @@ -400,10 +400,10 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller }, Security: DbSec, options: SqlOpt(fn, id, code)); _logger.LogDebug("Password changed successfully for user={User}", UserAccountID); - return Ok(); + return await JSONAsync(new { ok = true }); } _logger.LogWarning("HandleAccount unknown action={Action} user={User}", id, UserAccountID); - return Ok(); + return await JSONAsync(new { ok = true }); } private async Task HandleMfr(string fn, string id, string code) @@ -429,7 +429,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller } _logger.LogWarning("HandleMfr access denied for user={User} authorization={Auth}", UserAccountID, UserIdent.Authorization); - return Ok(); + return await JSONAsync(new { ok = true }); } private async Task HandleMfrUpdate(string fn, string id, string code) @@ -444,7 +444,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller using var mfrSingle = _mfrFactory.Create(); await mfrSingle.Update__entitytable(et, fds.FdsMfr.UpdateNeed.Short); _logger.LogDebug("MfrUpdate Short completed for entity={EntityType}", et); - return Ok(); + return await JSONAsync(new { ok = true }); } if (et != EntityTypes.none && !string.IsNullOrEmpty(Request.Form["need"])) { @@ -453,7 +453,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller using var mfr = _mfrFactory.Create(); await mfr.Update__entitytable(et, updateNeed: need, debugDetails: false); _logger.LogDebug("MfrUpdate completed for entity={EntityType} need={Need}", et, need); - return Ok(); + return await JSONAsync(new { ok = true }); } _logger.LogWarning("HandleMfrUpdate bad request: unknown type={Type} user={User}", typeParam, UserAccountID); return BadRequest400(); diff --git a/Fuchs/Docs/Decisions/0005-pdf-generation-and-erechnung.md b/Fuchs/Docs/Decisions/0005-pdf-generation-and-erechnung.md new file mode 100644 index 0000000..c3d2497 --- /dev/null +++ b/Fuchs/Docs/Decisions/0005-pdf-generation-and-erechnung.md @@ -0,0 +1,84 @@ +--- +status: Accepted +date: 2026-07-05 +applyTo: + - "Fuchs/code/FuchsPdf.cs" + - "Fuchs/Services/FuchsPdfService.cs" + - "Fuchs/Services/InvoiceService.cs" + - "Fuchs/Services/ReminderService.cs" + - "eRechnungLib/**" +supersededBy: "" +--- + +# 0005 — PDF generation, rendering, and eRechnung output + +## Context +Fuchs produces letters, invoices, and reminders as PDFs. The layout is a faithful +port of the legacy VB module `fuchs_fds_pdf.vb` (letterhead, DIN address window, +admin block, four-block footer with page numbers, invoice item table, GiroCode). +The port had silently drifted — wrong letterhead image filenames (`image1.png` +instead of the shipped `image1.jpeg`, which `AddHeaderImage` skips via +`File.Exists`), a too-small bottom margin, and a reworked footer/admin block — so +generated PDFs (e.g. the `sprep` invoice preview) rendered broken. + +Separately, German B2B/B2G invoicing now requires **eRechnung** (structured +electronic invoices). The company direction is that **all invoices are emitted as +eRechnung**, not just human-readable PDFs. + +Rendering also depends on **Spire.PDF** (commercial, licensed) for PDF/A +conversion and rasterising PDFs to preview images. + +## Decision +- **PDF layout stays a 1:1 port of the legacy `fuchs_fds_pdf.vb`.** `FuchsPdf` + (MigraDoc/PdfSharp) is the single source of the visual layout. When changing + the letter/invoice/reminder layout, compare against the legacy module and keep + the letterhead assets (`Fuchs/Data/image1-3.jpeg`, `image4.png`, `overlay.png`), + margins, sender line, label-over-value admin block, absolutely-positioned + four-block footer, and `Seite X von Y` page numbers aligned with it. Reference + the shipped asset filenames exactly — `AddHeaderImage` no-ops on a missing file, + so a wrong extension silently drops a logo. +- **Rendering pipeline:** `FuchsPdf.DocToPdfBytes` renders MigraDoc → PDF and + post-processes to PDF/A; `DocToImageCollection` / `BytesToImageCollection` + rasterise via Spire for the on-screen invoice preview (`sprep`/`sedit`). The + OCORE `OCOREFontResolver` must be installed before any PdfSharp rendering. +- **Spire license comes from a managed secret.** `FuchsPdfService` reads the + license from configuration key `SpirePdf_License` (Key Vault secret + `fuchs--SpirePdf-License`, registered in `ManagedSecretKeys`) and passes it to + `FuchsPdf.SetLicense(key)`. An embedded fallback key keeps local/dev rendering + working without Key Vault. +- **eRechnung via `eRechnungLib`.** The `eRechnungLib` submodule is the single + library for structured invoices. Invoices are to be produced as eRechnung: + build an `eRechnungLib.Model.Invoice` from the Fuchs invoice data, then + `EInvoice.CreateInvoice(model).ToZugferd(ZugferdProfile.EN16931, visualPdfBytes)` + to embed the CII XML into the FuchsPdf-rendered visual PDF (ZUGFeRD/Factur-X + hybrid PDF/A-3), or `ToXRechnung(...)` for pure UBL/CII XML. The visual PDF is + the FuchsPdf output — the two layers stay consistent (same amounts/parties). + Default `ConversionOptions` runs model + XSD validation; use `StrictValidation` + when a malformed invoice must withhold output rather than ship with findings. + +## Consequences +- Layout edits must be validated against the legacy reference and the shipped + `Data/` assets; do not invent new positions/sizes. The pipeline test + `Fuchs.Tests/PdfPipelineTests.cs` exercises the full chain (PdfSharp visual PDF + → Spire preview images → eRechnung hybrid/XML) and must stay green. +- Do **not** upgrade Spire.PDF beyond 8.10.5 (see project libraries rule). The + license must never be hard-coded in new code paths — read it from + `SpirePdf_License`. +- Wiring the app's invoice flow to emit eRechnung is the follow-up: map + `FdsInvoiceData`/`InvoiceRegistration` → `eRechnungLib.Model.Invoice` + (parties, lines, VAT breakdown, payment/IBAN, buyer reference, seller + electronic address) and persist/deliver the ZUGFeRD PDF and/or XRechnung XML. +- eRechnungLib depends only on open-source libraries (PDFsharp/MigraDoc; optional + SaxonCS-HE for Schematron) — no new commercial dependency for the structured + output itself. + +## Alternatives considered +- **Hand-rolling ZUGFeRD/XRechnung XML** in Fuchs: rejected — EN 16931 + CIUS + validation, multiple profiles/syntaxes, and PDF/A-3 embedding are error-prone; + a dedicated, validated library is safer. +- **Rewriting the PDF layout from scratch** rather than porting the legacy module: + rejected — the letterhead is a fixed corporate design; the legacy VB is the + authoritative spec, so faithful porting avoids visual regressions. +- **Bundling a Spire license file / hard-coding the key**: rejected in favor of + the managed-secret path so the production key is centrally rotated and never + committed, with the embedded key only as a dev fallback. diff --git a/Fuchs/Program.cs b/Fuchs/Program.cs index ce550e3..b2ad009 100644 --- a/Fuchs/Program.cs +++ b/Fuchs/Program.cs @@ -37,6 +37,14 @@ public class Program // Key Vault + DPAPI secret management (must run before FuchsOcmsIntranet.Initialize) builder.AddSecretManagement(); + // Apply the Spire.PDF license as early as possible — Spire evaluates its license + // lazily on the first PDF operation per process and caches the result, so it must be + // set before any Spire use (self-test, first render) or the evaluation watermark sticks + // for the whole process. Sourced from the SpirePdf-License managed secret (config key + // SpirePdf_License, plus tolerated spelling variants); falls back to the embedded key. + FuchsPdf.SetLicense( + FuchsPdfService.ResolveLicenseFromConfiguration(builder.Configuration, out _)); + // Assemble connection strings from templates + resolved credentials. // In Development, "_Dev"-suffixed credential keys are preferred so a reachable // Key Vault can never override them with production DB credentials. @@ -87,8 +95,10 @@ public class Program // Dev/test safety net: Fuchs:Email:OverrideRecipient redirects every outbound email // (see appsettings.Development.json) so real tenant-owners/end-customers are never emailed. builder.Services.Configure(builder.Configuration.GetSection("Fuchs:Email")); + builder.Services.Configure(builder.Configuration.GetSection("Fuchs:StartupChecks")); builder.Services.AddHttpClient("ProcessWebMailer"); builder.Services.AddScoped(); + builder.Services.AddHostedService(); // Business services (DI migration — replaces the static helper / Active-Record pattern) builder.Services.AddSingleton(); // stateless parser diff --git a/Fuchs/Services/FuchsPdfService.cs b/Fuchs/Services/FuchsPdfService.cs index 3e05209..ba6d1f8 100644 --- a/Fuchs/Services/FuchsPdfService.cs +++ b/Fuchs/Services/FuchsPdfService.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using Fuchs.intranet; using Fuchs.Observability; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using MigraDoc.DocumentObjectModel; @@ -13,13 +14,90 @@ namespace Fuchs.Services; /// public class FuchsPdfService : IPdfService { + /// + /// Canonical configuration key holding the Spire.PDF license. Sourced from the + /// SpirePdf-License managed secret (Key Vault name fuchs--SpirePdf-License): + /// the secret-management layer strips the fuchs-- app prefix and maps - to + /// _ per segment, so SpirePdf-License surfaces here as SpirePdf_License. + /// + internal const string LicenseConfigKey = "SpirePdf_License"; + + /// + /// Placeholder appsettings.json carries for the managed secret until Key Vault (or the + /// DPAPI cache) supplies the real value. Treated as "no license configured" so the embedded + /// fallback key is used instead of applying this literal as a bogus Spire license key — + /// mirrors the convention in . + /// + internal const string UnloadedSecretPlaceholder = "MANAGED_BY_KEYVAULT"; + + /// + /// Every config-key spelling the license can realistically surface under, tried in order. + /// The managed-secret mapping yields ; the other variants cover + /// a value provided verbatim (appsettings), a :-hierarchy, or an app-prefixed key. + /// + internal static readonly string[] LicenseConfigKeyCandidates = + { + LicenseConfigKey, // SpirePdf_License (managed-secret mapping) + "SpirePdf-License", // verbatim, e.g. appsettings.Development.json + "SpirePdf:License", // ':'-hierarchy variant + "SpirePdfLicense", // no separator + "fuchs:SpirePdf-License", // default KV manager on the full secret name + "Fuchs:SpirePdf_License", + }; + + /// + /// Resolves the Spire license value from configuration, tolerating the different key spellings + /// the secret can surface under. Returns when none carry a value; + /// reports which candidate matched (or ). + /// + internal static string? ResolveLicenseFromConfiguration(IConfiguration configuration, out string? matchedKey) + { + foreach (var key in LicenseConfigKeyCandidates) + { + string? value = configuration[key]; + if (!string.IsNullOrWhiteSpace(value) && + !string.Equals(value, UnloadedSecretPlaceholder, StringComparison.Ordinal)) + { + matchedKey = key; + return value; + } + } + matchedKey = null; + return null; + } + + /// Config keys that look Spire-related, for diagnostics when no candidate matched. + internal static IEnumerable SpireLikeConfigKeys(IConfiguration configuration) => + configuration.AsEnumerable() + .Where(kv => kv.Value is not null && + kv.Key.Contains("spire", StringComparison.OrdinalIgnoreCase)) + .Select(kv => kv.Key) + .Distinct(StringComparer.OrdinalIgnoreCase); + private readonly ILogger _logger; - public FuchsPdfService(ILogger logger) + public FuchsPdfService(ILogger logger, IConfiguration configuration) { _logger = logger; - FuchsPdf.SetLicense(); - _logger.LogDebug("FuchsPdfService initialised (PDF license applied)."); + // The license is normally applied once at startup (Program.cs) before any Spire use; + // re-applying here is a harmless safety net. If the managed secret is missing, the + // embedded fallback key is used — which does NOT license current Spire.PDF and leaves + // an evaluation watermark on rendered PDFs, so surface that as a warning. + string? licenseKey = ResolveLicenseFromConfiguration(configuration, out string? matchedKey); + FuchsPdf.SetLicense(licenseKey); + if (string.IsNullOrWhiteSpace(licenseKey)) + { + var spireKeys = SpireLikeConfigKeys(configuration).ToArray(); + _logger.LogWarning( + "Spire.PDF license not found under any known config key ({Candidates}). " + + "Config keys containing 'spire': [{FoundKeys}]. Using the embedded fallback key — " + + "rendered PDFs may carry the Spire evaluation watermark. Ensure the Key Vault secret " + + "'fuchs--SpirePdf-License' is present and reachable, or set it in appsettings.Development.json.", + string.Join(", ", LicenseConfigKeyCandidates), + spireKeys.Length > 0 ? string.Join(", ", spireKeys) : "(none)"); + } + else + _logger.LogInformation("Spire.PDF license applied from config key '{MatchedKey}'.", matchedKey); } public Task WriteLetterAsync(FuchsPdf.FdsTextBlocks textBlocks, bool draft) diff --git a/Fuchs/Services/StartupSelfTestService.cs b/Fuchs/Services/StartupSelfTestService.cs new file mode 100644 index 0000000..91bb1a4 --- /dev/null +++ b/Fuchs/Services/StartupSelfTestService.cs @@ -0,0 +1,345 @@ +using Azure; +using Azure.Security.KeyVault.Secrets; +using Fuchs.intranet; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Fuchs.Services; + +/// +/// One-shot startup self-test that can verify Key Vault connectivity and optionally +/// send a startup probe email. This service never throws to avoid blocking app startup. +/// +public class StartupSelfTestService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly IConfiguration _configuration; + private readonly StartupSelfTestSettings _settings; + private readonly ILogger _logger; + + public StartupSelfTestService( + IServiceProvider serviceProvider, + IConfiguration configuration, + IOptions settings, + ILogger logger) + { + _serviceProvider = serviceProvider; + _configuration = configuration; + _settings = settings.Value; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + => await RunOnceAsync(stoppingToken); + + internal async Task RunOnceAsync(CancellationToken stoppingToken) + { + if (!_settings.Enabled) + { + _logger.LogDebug("StartupSelfTestService skipped - Fuchs:StartupChecks:Enabled is false."); + return; + } + + bool keyVaultOk = true; + bool databaseOk = true; + bool mfrOk = true; + bool mailerOk = true; + bool pdfLicenseOk = true; + + try + { + if (_settings.CheckKeyVault) + { + try + { + keyVaultOk = await ProbeKeyVaultAsync(stoppingToken); + } + catch (Exception ex) + { + keyVaultOk = false; + _logger.LogWarning(ex, "Startup Key Vault check failed with an exception."); + } + } + + if (_settings.CheckDatabase) + { + try + { + databaseOk = await ProbeDatabaseAsync(stoppingToken); + } + catch (Exception ex) + { + databaseOk = false; + _logger.LogWarning(ex, "Startup database check failed with an exception."); + } + } + + if (_settings.CheckMfr) + { + try + { + mfrOk = await ProbeMfrAsync(stoppingToken); + } + catch (Exception ex) + { + mfrOk = false; + _logger.LogWarning(ex, "Startup MFR check failed with an exception."); + } + } + + if (_settings.CheckPdfLicense) + { + try + { + pdfLicenseOk = await ProbePdfLicenseAsync(stoppingToken); + } + catch (Exception ex) + { + pdfLicenseOk = false; + _logger.LogWarning(ex, "Startup PDF license check failed with an exception."); + } + } + + if (_settings.SendStartupEmail) + { + try + { + mailerOk = await SendStartupEmailAsync(stoppingToken); + } + catch (Exception ex) + { + mailerOk = false; + _logger.LogWarning(ex, "Startup mailer check failed with an exception."); + } + } + + _logger.LogInformation( + "Startup self-test completed. KeyVaultOk={KeyVaultOk}, DatabaseOk={DatabaseOk}, MfrOk={MfrOk}, MailerOk={MailerOk}, PdfLicenseOk={PdfLicenseOk}", + keyVaultOk, + databaseOk, + mfrOk, + mailerOk, + pdfLicenseOk); + } + catch (OperationCanceledException) + { + _logger.LogWarning("Startup self-test canceled."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Startup self-test failed unexpectedly."); + } + } + + protected virtual async Task ProbeDatabaseAsync(CancellationToken cancellationToken) + { + string? connectionString = _configuration.GetConnectionString("fuchs_fds_ConnectionString"); + if (string.IsNullOrWhiteSpace(connectionString)) + { + _logger.LogWarning("Startup database check skipped - ConnectionStrings:fuchs_fds_ConnectionString is empty."); + return false; + } + + try + { + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(cancellationToken); + await using var command = new SqlCommand("SELECT 1;", connection); + object? scalar = await command.ExecuteScalarAsync(cancellationToken); + bool ok = scalar is not null && scalar.ToString() == "1"; + if (!ok) + { + _logger.LogWarning("Startup database check failed - SELECT 1 returned '{Value}'.", scalar); + return false; + } + + _logger.LogInformation("Startup database check succeeded."); + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Startup database check failed."); + return false; + } + } + + protected virtual async Task ProbeMfrAsync(CancellationToken cancellationToken) + { + try + { + var factory = _serviceProvider.GetService(); + if (factory is null) + { + _logger.LogWarning("Startup MFR check skipped - IMfrClientFactory is not registered."); + return false; + } + + using var client = factory.Create(); + string entities = await client.GetEntities(throwErrorIfNotOk: true); + if (string.IsNullOrWhiteSpace(entities)) + { + _logger.LogWarning("Startup MFR check failed - empty response."); + return false; + } + + _logger.LogInformation("Startup MFR check succeeded."); + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Startup MFR check failed."); + return false; + } + } + + /// + /// Verifies the Spire.PDF license: the license string must be configured (present and + /// non-empty) and Spire.PDF must actually be licensed. Spire exposes no public validity + /// API, so the licensed state is probed by creating a tiny document and checking the + /// output for the evaluation watermark it stamps when unlicensed. + /// + protected virtual async Task ProbePdfLicenseAsync(CancellationToken cancellationToken) + { + string? licenseKey = FuchsPdfService.ResolveLicenseFromConfiguration(_configuration, out string? matchedKey); + if (string.IsNullOrWhiteSpace(licenseKey)) + { + var spireKeys = FuchsPdfService.SpireLikeConfigKeys(_configuration).ToArray(); + _logger.LogWarning( + "Startup PDF license check failed - no license found under any known config key ({Candidates}). " + + "Config keys containing 'spire': [{FoundKeys}]. Rendered PDFs will carry the Spire evaluation watermark.", + string.Join(", ", FuchsPdfService.LicenseConfigKeyCandidates), + spireKeys.Length > 0 ? string.Join(", ", spireKeys) : "(none)"); + return false; + } + + _logger.LogInformation("Startup PDF license check - license found under config key '{MatchedKey}'.", matchedKey); + // Ensure the configured key is applied, then confirm Spire is not in evaluation mode. + FuchsPdf.SetLicense(licenseKey); + bool licensed = await Task.Run(SpirePdfIsLicensed, cancellationToken); + + if (licensed) + _logger.LogInformation("Startup PDF license check succeeded - Spire.PDF is licensed."); + else + _logger.LogWarning( + "Startup PDF license check failed - Spire.PDF is in evaluation mode. The configured " + + "license key was rejected or does not cover this Spire.PDF version."); + return licensed; + } + + /// + /// Returns when Spire.PDF is licensed. Detects the evaluation edition + /// by rendering a minimal document and checking the extracted text for the watermark Spire + /// stamps on documents it creates while unlicensed. + /// + internal static bool SpirePdfIsLicensed() + { + try + { + using var doc = new Spire.Pdf.PdfDocument(); + var page = doc.Pages.Add(); + page.Canvas.DrawString( + "license probe", + new Spire.Pdf.Graphics.PdfFont(Spire.Pdf.Graphics.PdfFontFamily.Helvetica, 10f), + Spire.Pdf.Graphics.PdfBrushes.Black, + 10f, 10f); + + using var ms = new MemoryStream(); + doc.SaveToStream(ms, Spire.Pdf.FileFormat.PDF); + ms.Position = 0; + + using var check = new Spire.Pdf.PdfDocument(); + check.LoadFromStream(ms); + string text = check.Pages[0].ExtractText(); + + return !text.Contains("Evaluation Warning", StringComparison.OrdinalIgnoreCase) + && !text.Contains("created with Spire.PDF", StringComparison.OrdinalIgnoreCase); + } + catch + { + // A malformed/rejected license key makes Spire throw during validation on save; + // any failure to produce a clean licensed document means "not licensed". + return false; + } + } + + protected virtual async Task ProbeKeyVaultAsync(CancellationToken cancellationToken) + { + string appName = _configuration["SecretManagement:AppName"] ?? ""; + string[] managedKeys = _configuration.GetSection("SecretManagement:ManagedSecretKeys").Get() ?? []; + + if (string.IsNullOrWhiteSpace(appName) || managedKeys.Length == 0) + { + _logger.LogWarning("Startup Key Vault check skipped - SecretManagement settings are incomplete."); + return false; + } + + var secretClient = _serviceProvider.GetService(); + if (secretClient is null) + { + _logger.LogWarning("Startup Key Vault check skipped - SecretClient is not registered."); + return false; + } + + string probeName = $"{appName}--{managedKeys[0]}"; + + try + { + KeyVaultSecret secret = await secretClient.GetSecretAsync(probeName, version: null, cancellationToken); + if (string.IsNullOrWhiteSpace(secret.Value)) + { + _logger.LogWarning("Startup Key Vault check failed - secret '{SecretName}' is empty.", probeName); + return false; + } + + _logger.LogInformation("Startup Key Vault check succeeded using '{SecretName}'.", probeName); + return true; + } + catch (RequestFailedException ex) + { + _logger.LogWarning(ex, "Startup Key Vault check failed for '{SecretName}' with status {Status}.", probeName, ex.Status); + return false; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Startup Key Vault check failed for '{SecretName}'.", probeName); + return false; + } + } + + protected virtual async Task SendStartupEmailAsync(CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_settings.StartupEmailRecipient)) + { + _logger.LogWarning("Startup mailer check skipped - StartupEmailRecipient is empty."); + return false; + } + + using var scope = _serviceProvider.CreateScope(); + var comService = scope.ServiceProvider.GetRequiredService(); + + string subject = $"[Startup] Fuchs Intranet started on {Environment.MachineName}"; + string html = $"

Fuchs Intranet startup probe.

" + + $"

UTC: {DateTimeOffset.UtcNow:O}
Machine: {Environment.MachineName}

"; + + bool sent = await comService.SendEmailAsync( + "startup_probe", + subject, + html, + _settings.StartupEmailRecipient, + _settings.StartupEmailRecipientName, + attachments: null); + + if (!sent) + { + _logger.LogWarning("Startup mailer check failed - probe email was not accepted by IComService."); + return false; + } + + _logger.LogInformation("Startup mailer check succeeded."); + return true; + } +} diff --git a/Fuchs/Services/StartupSelfTestSettings.cs b/Fuchs/Services/StartupSelfTestSettings.cs new file mode 100644 index 0000000..5c54f5d --- /dev/null +++ b/Fuchs/Services/StartupSelfTestSettings.cs @@ -0,0 +1,47 @@ +namespace Fuchs.Services; + +/// +/// Optional one-shot startup self-test settings, bound from "Fuchs:StartupChecks". +/// Disabled by default to avoid accidental startup emails in production. +/// +public class StartupSelfTestSettings +{ + public bool Enabled { get; set; } = false; + + /// + /// When enabled, verifies Key Vault access by reading one managed secret. + /// + public bool CheckKeyVault { get; set; } = true; + + /// + /// When enabled, verifies SQL database connectivity by executing SELECT 1. + /// + public bool CheckDatabase { get; set; } = true; + + /// + /// When enabled, verifies MFR API connectivity using the configured client credentials. + /// + public bool CheckMfr { get; set; } = true; + + /// + /// When enabled, verifies that a Spire.PDF license string is configured (present and + /// non-empty) and that Spire.PDF is actually licensed (not running in evaluation mode). + /// + public bool CheckPdfLicense { get; set; } = true; + + /// + /// When enabled, sends a startup probe email via . + /// This applies to all environments, including Production. + /// + public bool SendStartupEmail { get; set; } = false; + + /// + /// Recipient address for startup probe emails. + /// + public string StartupEmailRecipient { get; set; } = ""; + + /// + /// Recipient display name for startup probe emails. + /// + public string StartupEmailRecipientName { get; set; } = "Startup Monitor"; +} diff --git a/Fuchs/appsettings.Development.json b/Fuchs/appsettings.Development.json index d22416c..5cf9ec1 100644 --- a/Fuchs/appsettings.Development.json +++ b/Fuchs/appsettings.Development.json @@ -6,15 +6,24 @@ }, "Logging": { "LogLevel": { - "Default": "Warning", - "Microsoft.AspNetCore": "Warning", - "Microsoft.Hosting.Lifetime": "Information" + "Default": "Debug", + "Microsoft.AspNetCore": "Debug", + "Microsoft.Hosting.Lifetime": "Debug", + "fds": "Debug", + "Fuchs.Controllers": "Debug" } }, "Fuchs": { "FDS_Intranet_DebugState": true, "DevAutoLogin": true, "DevAutoLoginEmail": "info@processweb.de", + "StartupChecks": { + "Enabled": true, + "CheckKeyVault": false, + "CheckDatabase": false, + "CheckMfr": false, + "CheckPdfLicense": true + }, "Email": { "OverrideRecipient": "service@emails.processweb.de" }, diff --git a/Fuchs/appsettings.json b/Fuchs/appsettings.json index 03dbbc9..d546cb8 100644 --- a/Fuchs/appsettings.json +++ b/Fuchs/appsettings.json @@ -13,7 +13,8 @@ "Fuchs--fuchs-captcha-TOTP", "Fuchs--fuchs-intranet-TOTP", "Fds--MFR-UserName", - "Fds--MFR-Password" + "Fds--MFR-Password", + "SpirePdf-License" ] }, "Logging": { @@ -23,6 +24,7 @@ } }, "AllowedHosts": "*", + "SpirePdf_License": "MANAGED_BY_KEYVAULT", "ConnectionStrings": { "fuchs_fds_ConnectionString": "Data Source=MSSQL4.NBG4.DOMAINXYZ.DE,10439;Initial Catalog=site_fuchs_dev;Persist Security Info=False;TrustServerCertificate=true;Encrypt=true;User ID={username};password='{password}';Connect Timeout=60;Load Balance Timeout=240;Max Pool Size=500;", "fuchs_fds_username": "MANAGED_BY_KEYVAULT", @@ -40,13 +42,22 @@ "SMS_APIKey": "MANAGED_BY_KEYVAULT", "Mailer": { "BaseUrl": "https://api.processweb.de", - "AccountId": "", + "AccountId": "82d87114-c8c3-4d33-95e5-4c781a9229ab", "Token": "MANAGED_BY_KEYVAULT", "Enabled": false }, "Email": { "OverrideRecipient": "" }, + "StartupChecks": { + "Enabled": false, + "CheckKeyVault": true, + "CheckDatabase": true, + "CheckMfr": true, + "SendStartupEmail": true, + "StartupEmailRecipient": "", + "StartupEmailRecipientName": "Startup Monitor" + }, "AzureStorage": { "Enabled": false, "InvoiceContainer": "fuchs-invoices", diff --git a/Fuchs/code/FuchsPdf.cs b/Fuchs/code/FuchsPdf.cs index 0ddd3ed..e2075a8 100644 --- a/Fuchs/code/FuchsPdf.cs +++ b/Fuchs/code/FuchsPdf.cs @@ -22,8 +22,23 @@ public static class FuchsPdf public const string ProjectAbbreviation = "fuchs"; // ── Spire license ───────────────────────────────────────────────────────── - public static void SetLicense() => - Spire.License.LicenseProvider.SetLicenseKey( + /// + /// Applies the Spire.PDF license. The key is supplied by the caller from the + /// SpirePdf-License managed secret (config key SpirePdf_License); + /// when no key is provided the embedded fallback key is used so PDF rendering + /// still works in local/dev setups without Key Vault access. + /// + public static void SetLicense(string? licenseKey = null) => + Spire.License.LicenseProvider.SetLicenseKey(ResolveLicenseKey(licenseKey)); + + /// + /// Chooses the effective Spire license key: the managed-secret value when present, + /// otherwise the embedded fallback. Pure/side-effect-free for testability. + /// + internal static string ResolveLicenseKey(string? licenseKey) => + string.IsNullOrWhiteSpace(licenseKey) ? EmbeddedLicenseKey : licenseKey; + + private const string EmbeddedLicenseKey = "I+ztXu/77JVCXwEAwVQwRISgL4qlo1lOxO6csGdd02iJsOnMzEkqjhRx6oJ5rw5fgaF5wUf83LWMWwLE8PNc" + "/ZGUZIa8mTx9ovjM9fK2+xLk/VC3s555Qhd5+PLfgxIEsp4r6lw03P7YPvD6pvM745VQg0dd8thRoznmkWrkUf" + "/2/MiUZyUyVrH+qyEZgkniqpuDdqoaUNx1RfsK6TyiKKB7nsiqDy9xrduuYCMgOg1wii3aU+anA/pHUYh/jMO0" + @@ -41,7 +56,7 @@ public static class FuchsPdf "uUg5LlJmPPXkTKHQJ/CM6EQkqIS4Foz7pBaaYRBgEz/zDujxbYUGN6LaJiANung4Zyl6k5arhHdCalRDe29avN1o" + "vxe/5tUHQQDxq+yQ1cNChPJTFHR1bKKu0T7SW7p19qH5850rXcjtzK4+6zGYXq8HItH6UNiev27o9VUoKTv+XZiD" + "27YE33vdwQHh5Kdc8CMMo+uaTI11uLBirUH63Na2oBkCGJjJzQk8Gc5NQs7+2DptJ/rNlOhwb/czZLB6OjH+vNCy" + - "HZBCGPd17rIW16JQzgWv+OBI9DbD7pXYzDyF++IrBiRKBPNKCTwg3trm89J4zWeGW80bFtD0QnIcArA=="); + "HZBCGPd17rIW16JQzgWv+OBI9DbD7pXYzDyF++IrBiRKBPNKCTwg3trm89J4zWeGW80bFtD0QnIcArA=="; // ── Colors ──────────────────────────────────────────────────────────────── private static readonly Color FuchsGray = Color.FromRgb(128, 128, 128); @@ -193,21 +208,36 @@ public static class FuchsPdf DefineStyles_Letter(doc, Array.Empty