Compare commits

..
27 Commits
Author SHA1 Message Date
Stefan 24b94b05c2 Add invoice provision-period PDF handling
Playwright Tests / test (push) Canceled after 0s
Normalize invoice provision periods in PDF generation, switch admin labels between single-date and range modes, add end-to-end tests, update package and frontend bundle versions, and note the invoice lifecycle docs update.
2026-10-09 12:03:06 +02:00
StefanandClaude Opus 5.5 4443bba63c fix(forms): never submit an invalid form; unlock password-change dialog
Playwright Tests / test (push) Canceled after 0s
serializeObject() returns null for an invalid form, but most callers sent
the result anyway. Add $.fn.rejectInvalid (stop the spinner, let the
browser report the offending field) and use it at every call site.

Bugs fixed:
- Password change: on a mismatch the spinner kept running, and the custom
  validity on "npw" was never cleared, so the browser blocked every later
  submit even after the passwords matched. It is now cleared as soon as
  either password field is edited.
- sendForm: options.complete referenced an undefined jqXHR (ReferenceError)
  and an invalid form was posted with partial data.
- login.send: an invalid form threw a TypeError on qs.loginaccount.
- postXT({ form }): an invalid form was posted with empty data.
- Form dialogs without required fields had no "Bitte überprüfen Sie Ihre
  Eingaben" note, so a rejected submit showed nothing.

Hardening: null guards for the user, user-group, re-login, SMS password,
admin specialties and report-parameter submits.

Add e2e_tests/form-validation.cjs covering each path against the real
scripts; it fails on the previous code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:11:05 +02:00
StefanandClaude Opus 5.5 4145ab699c fix(invoices): Leistungsdatum editor lost or mis-formatted the service date
Playwright Tests / test (push) Canceled after 0s
- serializeObject: an empty optional date field was run through parseDt,
  became null and invalidated the whole form, so leaving "bis" empty made
  dlgform call success with only loginaccount and wiped the stored period.
  Empty optional date fields are now sent as ''.
- dlgform: an invalid form no longer calls success with partial data; it
  stops the spinner and keeps the dialog open.
- eProvisionPeriod: the date inputs return yyyy-MM-dd, which was saved
  verbatim; it is now stored as German dd.MM.yyyy as documented (PDF,
  eRechnung mapping). ISO values saved by the old editor are accepted when
  reopening, so their fields are prefilled instead of empty.
- Add a Playwright regression test (e2e_tests/invoice-provisionperiod.cjs)
  driving the real form builder, serializer and dialog submit handler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:04:03 +02:00
Stefan e2faf52cd2 chore: Update submodule commits for OCORE and OCORE_web
Playwright Tests / test (push) Canceled after 0s
2026-10-06 16:41:08 +02:00
StefanandClaude Fable 5.1 546648533b fix(drafts): serialize draft session flushes and register reminders via fds__setReminder
Playwright Tests / test (push) Canceled after 0s
Make invoice/reminder live-draft edits concurrency-safe with an explicit
DraftFlushResult, add the fds__setReminder stored procedure, and harden the
date helper functions. Includes concurrency and SQL contract tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 13:57:40 +02:00
StefanandClaude Opus 5.5 d0002ccdad fix(invoices): send invoice amounts as exact cent-rounded decimals
Playwright Tests / test (push) Canceled after 0s
Stornorechnung saves still failed: the VAT amount was formatted invariantly
("-615.3757999999999") and re-parsed under the server's de-DE culture as
-6153757999999999, overflowing numeric(10,3) in fds__setInvoice.

- FdsInvoiceData.BuildInvoiceParams: amount params are decimal(10,x) via
  AmountParam/ParseAmount (culture-independent, rounded to cents); amounts
  beyond +/-9,999,999.99 throw InvoiceAmountOutOfRangeException.
- InvoiceService.RegisterInvoiceAsync: refuses such an invoice with a logged
  error (EventId InvoiceAmountRejected) instead of storing a wrong amount.
- Invoice editor: vat_val is cent-rounded decimal, no float noise.
- Tests: InvoiceAmountParamTests replaces the float-based Storno test.
- Bump Azure.Storage.Blobs 12.30.0; OCORE/OCORE_web submodules (AngleSharp
  1.8.3, Azure.Storage.Files.Shares 12.28.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:57:41 +02:00
StefanandClaude Opus 4.8 0a8384ab33 fix(invoices): Stornorechnung save failed on negative totals
Customer log: /do/inv/dsave failed with "Error converting data type float to
numeric" followed by "Cannot insert the value NULL into column 'InvId'".

- Bump OCORE: SQL_Float/SQL_Decimal string overloads parsed negatives via the
  de-DE server culture ("-19.95" -> -1995); float noise overflowed
  numeric(10,3), so fds__setInvoice never ran.
- InvoiceService: the header save batch now stops with a clear THROW when
  fds__createInvoice/fds__setInvoice yields no @Id, instead of failing again
  in fds__createInvoice_Details and burying the real error. Batch SQL
  extracted to BuildRegisterInvoiceSql for testing.
- Tests: signed/culture parsing under de-DE, Storno param build against the
  proc numeric limits, and the batch guard ordering.
- hlp: read-only script to find invoice/reminder amounts stored scaled up
  by the bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-01 10:07:13 +02:00
Stefan c741535aec feat: Implement mfr truncation fix and enhance invoice handling
Playwright Tests / test (push) Canceled after 0s
- Added a new SQL script to address the "String or binary data would be truncated" error in mfr sync by widening the Note fields in service objects and companies to NVARCHAR(MAX).
- Updated the table types and associated stored procedures to accommodate the changes, ensuring compatibility with existing data.
- Queued a re-sync for previously rejected rows due to truncation issues.
- Created application logins for fuchs_fds and fuchs_web with placeholder passwords for initial setup.
- Introduced functions for invoice backdating and invoice date retrieval to streamline invoice processing.
- Developed a password generation function with customizable parameters for length and character types.
- Added a random number generation function to support password creation.
2026-09-30 16:56:08 +02:00
Stefan 7357f60120 chore: Update submodule commits for OCORE and OCORE_web
Playwright Tests / test (push) Canceled after 0s
2026-09-30 10:20:29 +02:00
Stefan 63b3683725 Bump dependencies and submodule revisions
Update solution-wide package versions and advance OCORE, OCORE_Charting, and OCORE_web submodules.
2026-09-30 10:19:58 +02:00
Stefan a36f0d859d feat: Implement secret management using Azure Key Vault and DPAPI across OCORE and OCORE_web
Playwright Tests / test (push) Canceled after 0s
2026-09-22 21:49:49 +02:00
StefanandClaude Opus 4.8 4bf56c9699 chore: Update OCORE submodules and align package versions
Playwright Tests / test (push) Canceled after 0s
Pull OCORE, OCORE_web and OCORE_web_pdf to their latest main commits.
OCORE bumped MimeKit to 4.18.1 and Microsoft.Data.SqlClient to 7.1.0,
which made restore fail with NU1605 package-downgrade errors in Fuchs
and Fuchs_DataService; raise both pins to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-22 21:48:46 +02:00
Stefan a334497c82 feat: Add documentation for secret management using Azure Key Vault and DPAPI
Playwright Tests / test (push) Canceled after 0s
2026-09-18 14:14:56 +02:00
Stefan 65034ba2e8 docs: Update admin-system-status documentation to include detailed card descriptions and data sources
Playwright Tests / test (push) Canceled after 0s
2026-09-18 13:44:59 +02:00
Stefan e628454030 fix: Adjust banking transactions function to include manual credits in payment calculations
Playwright Tests / test (push) Canceled after 0s
2026-09-18 12:33:47 +02:00
Stefan 9569c1b65e feat: Enhance invoice file backup procedure to include JSON snapshots
Playwright Tests / test (push) Canceled after 0s
- Updated the stored procedure `fds__admin_backupAndClearInvoiceFile.sql` to back up not only the invoice file but also JSON snapshots of related data (invoices, items, details, service requests) to ensure consistency and traceability.
- Modified the table structure of `fds__invoice_file_backups` to accommodate new JSON columns for storing these snapshots.
- Added an ADR (0030) document to outline the decision and rationale behind these changes, emphasizing the importance of maintaining a consistent set of data alongside the backed-up file.
2026-09-17 22:39:01 +02:00
Stefan 249a945f5e feat: Implement admin-only specialties management interface
Playwright Tests / test (push) Canceled after 0s
- Added MailerAttachmentPolicy for validating email attachments against server rules.
- Introduced fis.specialties.js for managing hidden maintenance actions via an admin interface.
- Created fis.specialties.scss for styling the specialties management UI.
- Added German localization for specialties management in fis.specialties_txt_de.js.
- Developed CSS for specialties management in fis.specialties.css.
- Implemented server-side stored procedure fds__admin_backupAndClearInvoiceFile for backing up and clearing invoice files.
- Created fds__invoice_file_backups table to store backup records of invoice files.
2026-09-17 21:17:23 +02:00
Stefan 86f6a60860 feat: implement set-based invoice payment calculations and enhance email logging configuration
Playwright Tests / test (push) Canceled after 0s
2026-09-16 22:31:27 +02:00
Stefan 944a5bb442 Refactor document name sanitization for consistent filename generation
- Introduced FdsDocumentNames class to centralize the logic for sanitizing document names used in downloads.
- Updated FdsMfr to utilize the new SafeFileName method for generating safe filenames.
- Enhanced documentation for FdsDocumentNames and its methods to clarify usage and purpose.
- Added global.json file to configure testing framework settings.
2026-09-16 22:18:42 +02:00
Stefan a60d4c2eaf fix: update mfr__updt__companies procedure and mfr__tt__companies type for improved data handling 2026-09-16 21:52:53 +02:00
Stefan dcfc459693 feat: update launch and task configurations for improved debugging and release builds 2026-09-16 21:22:10 +02:00
StefanandClaude Opus 5 4cac109283 fix: detect no-op document stores via OUTPUT rowset; sanitise regenerated filename
Follow-up to 99bf533 from code review:

- The AffectedRows == 0 guard in Store{Invoice,Reminder}DocumentFileAsync
  was dead: fds__setInvoiceFile / fds__setReminderFile run SET NOCOUNT ON,
  so ExecuteNonQuery returns -1 whether or not the UPDATE ... WHERE [file]
  IS NULL matched. Both procs already return the updated Id through their
  OUTPUT clause, so read that result set (getSQLDatatable_async) instead:
  empty rowset = no-op store, logged, and the blob upload / FileCreated
  event are skipped rather than reporting unpersisted bytes as success.
- inv/rdoc's regenerate path built the download name from the raw
  DocumentName; a user-typed title with '/' truncated FileInfo.Name.
  SafeDocumentFileName strips editor markup and every invalid filename
  char, and the path no longer goes through FileInfo at all.
- Reminder counterpart tests for the store-file failure branches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:16:49 +02:00
StefanandClaude Sonnet 5 99bf5337f8 fix: invoice/reminder PDF preview 404s and their unlogged root causes
An invoice's finalisation-time PDF render/store (fds__setInvoiceFile) can
fail silently; the row stays finalised with [file] = NULL and inv/rdoc
(the invoice list's preview icon) 404'd forever with no trace in any log.

- inv/rdoc and rem/idoc now regenerate and persist a missing document on
  read, mirroring the legacy VB "idoc" fallback (Fuchs_Database ADR 0025),
  gated to finalised + non-external invoices only — a draft render must
  never be persisted, since fds__setInvoiceFile/fds__setReminderFile only
  write WHERE [file] IS NULL and finalisation would then silently no-op.
- StoreInvoiceDocumentFileAsync/StoreReminderDocumentFileAsync now log
  every distinct failure (render threw, render produced 0 bytes, DB write
  threw, DB write matched 0 rows) instead of collapsing them into a single
  silent empty-array return. The 0-rows case needed switching from
  setSQLValue_async (reports success once the command completes, whatever
  the row count) to setSQLValue_async_result, whose AffectedRows makes a
  no-op UPDATE distinguishable from an actual write for the first time.
- HandleRequestIdoc/HandleReminderIdoc no longer treat
  StoreInvoiceDocumentFileAsync's Array.Empty<byte>() failure signal as
  success (a bare `ct == null` check never caught it), which would have
  served a 0-byte "PDF".
- rdoc/HandleRequestIdoc reject a missing 'id' form field instead of doing
  wasted work with an empty id.
- _BackupAndClearInvoiceFile.sql is annotated as a manual-only maintenance
  utility, never called from app code, so it's clear why running it leaves
  no application log trail.
- Client-side: $inv.pdfPreviewTimeout was 120 (ms) instead of 120000 — the
  preview dialog was timing out client-side after 120ms on every call.
- Fixed a genuine process-wide test flake: PdfPipelineTests and
  ERechnungMapperTests both install PdfSharp's global font resolver, and
  whichever ran first would win, making the other's tests fail
  deterministically once eRechnungLib's SystemFontResolver got there
  first. FuchsPdf.EnsureFontResolver is now also race-safe under
  concurrent first use (a check-then-set without a lock). Also removed
  the InvoiceService.PublishAsync failure logging added mid-session after
  code review found it duplicated every call site's own, more detailed
  logging against ADR 0024's single-write-path rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 21:07:24 +02:00
StefanandClaude Sonnet 5 b5613bee0d docs: sync CA1416 build note across CLAUDE.md/CODEX.md/copilot-instructions.md
CLAUDE.md and CODEX.md already documented that CA1416 is suppressed
solution-wide via the root .editorconfig; copilot-instructions.md was
missing that note, breaking the three-file sync rule the header of each
file requires. Also adds the .editorconfig itself, which the note
references and which was untracked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 21:07:07 +02:00
StefanandClaude Sonnet 5 bcf1f2dbb6 feat: migrate DB error logging onto the generic OCORE MultiLogger SQL sink
Playwright Tests / test (push) Canceled after 0s
Retires Fuchs_intranet.debug_log() and every direct EXECUTE [dbo].[fds__admin_logdebug]
call in favor of a single path: ILogger. A new SQL log sink (OCORE.MultiLogger.SqlLoggerProvider,
gated by Fuchs:Logging:SqlSink:Enabled, default on) mirrors every _logger.LogWarning/-Error/
-Critical call into [dbo].[fds__debuglog] via a new fds__admin_logdebug_ext proc, at Warning+.

- Program.cs registers the sink and RequestLogScopeMiddleware (request id/path/authenticated
  user, pushed after UseAuthentication) so every log line in a request — including ones written
  deep inside OCORE's SQL helpers — is attributed automatically.
- FIS_SQLOptions, IntranetController.Do's catch-all, PdfPreviewAsync, and the banking-upload
  handlers now log once, with an explicit EventId preserving the exact CodeReference text the
  retired debug_log() calls used to write, so existing dashboards/queries over fds__debuglog
  keep working unchanged.
- Fuchs_DataService/FdsMfr.cs's FdsSqlOptions had the identical double-write hazard (it's hosted
  in the same process per ADR 0010) — the original 2026-09-16 log review's mfr__getSchema/
  fds__getUpdateableRequests deadlock rows came from exactly this class. Fixed the same way.
- fds__debuglog gains LogLevel/Machine/Category/Account/AuthUser/IsDebug columns; the legacy
  fds__admin_logdebug proc is left in place unused, not dropped.
- FdsDebug.DebugLog (Fuchs_DataService's other ~9 call sites) deliberately left on its direct
  DB-write path — no ILogger call sits alongside it there, so no duplication risk today.

See ADR 0024 (supersedes 0023) and Docs/Concepts/error-logging-pipeline.md.

New tests: ErrorLoggingTests (FIS_SQLOptions), FdsSqlOptionsTests, RequestLogScopeMiddlewareTests.
694/694 pass (the 4 PdfPipelineTests failures seen under full-suite parallel runs are a
pre-existing, unrelated PdfSharp global-font-resolver test-isolation flake — they pass both
individually and in most full-suite runs).

Bumps the OCORE submodule to 3137b56 (SQL sink hardening: redaction matches short fragments
as whole identifier segments, per-entry-tolerant serialization, lazy dataSerialized, opt-in
SqlLoggerOptions.IncludeCategory, LogEntry.Category/AuthUser capture).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 17:20:06 +02:00
Stefan a1e6002c3b chore: Update OCORE and OCORE_web submodules to latest commits
Playwright Tests / test (push) Canceled after 0s
2026-09-16 16:35:35 +02:00
Stefan fce83c528f feat: Enhance error logging for PDF preview failures and SQL timeouts
- Implemented a new method `ShapeTableNames` to gather entity and navigation table names, ensuring no blanks or duplicates are included.
- Updated `FdsMfrClient` to handle cases where schema queries return no tables, logging appropriate warnings and preventing misleading errors.
- Introduced `PdfPreviewAsync` in `IntranetController` to manage PDF preview rendering, capturing detailed logs for failures and notifying users with readable error messages.
- Added comprehensive unit tests for error logging and table name shaping to ensure robustness and correctness.
- Documented decision to persist user action failures to the `fds__admin_logdebug` table, enhancing support diagnostics.
2026-09-16 16:35:04 +02:00
210 changed files with 9798 additions and 1924 deletions
+7
View File
@@ -0,0 +1,7 @@
root = true
[*.cs]
# CA1416: Validate platform compatibility. This app targets Windows only (System.Drawing,
# registry, etc. usage throughout Fuchs/OCORE); the platform-compat analyzer's warnings here
# are expected noise, not signal. See CLAUDE.md "Build & Test" for the pre-existing-warnings note.
dotnet_diagnostic.CA1416.severity = none
+8 -3
View File
@@ -13,8 +13,10 @@
- **Fuchs Intranet** is an ASP.NET Core (.NET 10) web application — the intranet IS the entire website, served from `/`. - **Fuchs Intranet** is an ASP.NET Core (.NET 10) web application — the intranet IS the entire website, served from `/`.
- Routes: `/{fn?}/{id?}/{code?}` → `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` → `IntranetController.Do`. - Routes: `/{fn?}/{id?}/{code?}` → `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` → `IntranetController.Do`.
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`. - Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Build the database project whenever anything under `Fuchs_Database/` changes: `msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug` (MSBuild from the Visual Studio install, e.g. `"C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe"`; `dotnet build` cannot build the legacy SSDT project). T-SQL warnings are build errors there (ADR 0036): an unresolved or ambiguous column (SQL71502) fails the build, and a red database build is a failure like a red C# build. The working-tree SQL is **not** on any database until it is published, so this build is the only check the SQL gets before review.
- 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. - 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`. - Test: `dotnet test --project Fuchs.Tests/Fuchs.Tests.csproj -c Debug` (the root `global.json` opts `dotnet test` into the Microsoft.Testing.Platform runner that xunit.v3 needs on the .NET 10 SDK; the positional-path form no longer works).
- Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
- Submodules include the OCORE projects and `eRechnungLib` (ZUGFeRD/Factur-X + XRechnung generation) — invoices are moving to eRechnung output. - Submodules include the OCORE projects and `eRechnungLib` (ZUGFeRD/Factur-X + XRechnung generation) — invoices are moving to eRechnung output.
- Project structure (relative to `Fuchs/`): - Project structure (relative to `Fuchs/`):
- `Controllers/` — `IntranetController` partials (no area) - `Controllers/` — `IntranetController` partials (no area)
@@ -48,7 +50,7 @@
## Services & Dependency Injection ## Services & Dependency Injection
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces; inject them into `IntranetController` (constructor injection). Do **not** reintroduce static God-classes or pass the whole controller into helpers. - Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces; inject them into `IntranetController` (constructor injection). Do **not** reintroduce static God-classes or pass the whole controller into helpers.
- `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). - `IComService` (email/SMS via ProcessWeb Mailer API — contract at **https://processweb.de/api/mailer/fc**, read it and `Fuchs/Docs/Concepts/processweb-mailer-interface.md` before changing it. `ProcessWebComService` is a thin adapter over the OCORE_web `IPWMailerClientService` (`OCORE_web/comm/pwmailer/`, owns the HTTP contract; ADR 0029) with `FuchsMailerClient` overriding `ValidateMessage` for attachment limits; a send only counts as successful when the receipt's `attachmentsProcessed` matches the number sent (ADR 0028); honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4).
- Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`, `ISystemStatusService`) are scoped. Register in `Program.cs`. - Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`, `ISystemStatusService`) are scoped. Register in `Program.cs`.
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders** (parse + properties). Loading, persistence and PDF generation belong in the services — never `Task.Run(...).Wait()` sync-over-async. - `FdsInvoiceData` / `FdsReminderData` are **pure data holders** (parse + properties). Loading, persistence and PDF generation belong in the services — never `Task.Run(...).Wait()` sync-over-async.
- Data access stays SQL-first via OCORE helpers (`getSQLDataSet_async`, `setSQLValue_async`) + stored procedures; no EF Core. - Data access stays SQL-first via OCORE helpers (`getSQLDataSet_async`, `setSQLValue_async`) + stored procedures; no EF Core.
@@ -60,6 +62,8 @@
## Database ## Database
- The SQL schema source of truth is the **`Fuchs_Database`** SSDT project. The backend is SQL-first (stored procedures, table types like `fds__tt__bankingtransactions`, functions via OCORE helpers — no EF Core). - The SQL schema source of truth is the **`Fuchs_Database`** SSDT project. The backend is SQL-first (stored procedures, table types like `fds__tt__bankingtransactions`, functions via OCORE helpers — no EF Core).
- When you change a stored proc name/params or a table type, update **both** the SSDT project and the calling C# in the same change. Verify every `[dbo].[…]` the backend calls actually exists in `Fuchs_Database`. - When you change a stored proc name/params or a table type, update **both** the SSDT project and the calling C# in the same change. Verify every `[dbo].[…]` the backend calls actually exists in `Fuchs_Database`.
- Objects the SQL references must be **in the project** (a referenced-but-missing function only shows up as an SSDT build error now; the `ocms_*` helpers are vendored copies from the OCMSsharp `ocms_mt_database` project). `Security/Logins.sql` holds placeholder logins only so the users bind — real passwords live in Key Vault.
- `Fuchs.Tests/SqlContractTests` checks every `[dbo].[…]` named in C# against the SSDT files (exists, `EXECUTE` binds, function arg counts; ADR 0036) — a proc signature change that breaks a caller fails the test suite.
## Bank statement parsing (MT940 + CAMT) ## Bank statement parsing (MT940 + CAMT)
- Two parsers feed the same banking pipeline: the external `MT940Parser` (SWIFT text) and the in-repo **`CAMTParser`** project (ISO 20022 camt.052/053/054 XML). - Two parsers feed the same banking pipeline: the external `MT940Parser` (SWIFT text) and the in-repo **`CAMTParser`** project (ISO 20022 camt.052/053/054 XML).
@@ -68,8 +72,9 @@
## Observability ## Observability
- Use **OpenTelemetry**. The app's instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource` + one `Meter`). - Use **OpenTelemetry**. The app's instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource` + one `Meter`).
- When adding a meaningful operation: start an activity (`FuchsTelemetry.StartActivity(...)`), record the relevant counter/histogram, and log entry/result/timing/errors via the injected `ILogger<T>`. Prefer structured logging (named placeholders), never string interpolation in log messages. - When adding a meaningful operation: start an activity (`FuchsTelemetry.StartActivity(...)`), record the relevant counter/histogram, and log entry/result/timing/errors via the injected `ILogger<T>`. Prefer structured logging (named placeholders), never string interpolation in log messages — and never `{@Foo}`: plain `Microsoft.Extensions.Logging` (unlike Serilog) does not strip the `@`, it becomes a literal part of the key.
- Tracing/metrics are always collected; OTLP export is opt-in via `Fuchs:Telemetry:OtlpEndpoint`. Don't add exporters that fail hard when no collector is present. - Tracing/metrics are always collected; OTLP export is opt-in via `Fuchs:Telemetry:OtlpEndpoint`. Don't add exporters that fail hard when no collector is present.
- **DB log sink (ADR 0024):** every `_logger.LogWarning`/`-Error`/`-Critical` call additionally lands in `[dbo].[fds__debuglog]` via the OCORE MultiLogger SQL sink (`fds__admin_logdebug_ext`), gated by `Fuchs:Logging:SqlSink:Enabled` (default on). `RequestLogScopeMiddleware` (`Fuchs/Logging/`) attributes every log line in a request to a request id/path/authenticated user automatically. Give a log call an explicit `EventId` (its `Name` becomes the row's `CodeReference`) when the call site should be individually greppable — see `FIS_SQLOptions`, `IntranetController.Do`'s catch-all, `PdfPreviewAsync`, and the banking-upload handlers for the pattern. Do not also call the retired `Fuchs_intranet.debug_log` — one `ILogger` call is the single write path; a second explicit DB write duplicates the row.
## Testing ## Testing
- xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial: - xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial:
+24 -1
View File
@@ -2,7 +2,7 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Fuchs: Debug ASP.NET Core", "name": "Fuchs: Debug (Debug config, Development env)",
"type": "coreclr", "type": "coreclr",
"request": "launch", "request": "launch",
"preLaunchTask": "build Fuchs", "preLaunchTask": "build Fuchs",
@@ -23,6 +23,29 @@
"sourceFileMap": { "sourceFileMap": {
"/Views": "${workspaceFolder}/Fuchs/Views" "/Views": "${workspaceFolder}/Fuchs/Views"
} }
},
{
"name": "Fuchs: Launch (Release config, Production env)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build Fuchs Release",
"program": "${workspaceFolder}/Fuchs/bin/Release/net10.0/Fuchs.dll",
"args": [],
"cwd": "${workspaceFolder}/Fuchs",
"stopAtEntry": false,
"justMyCode": true,
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)",
"uriFormat": "%s"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Production",
"ASPNETCORE_URLS": "https://localhost:63661;http://localhost:63662"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Fuchs/Views"
}
} }
] ]
} }
+15
View File
@@ -19,6 +19,21 @@
"isDefault": true "isDefault": true
} }
}, },
{
"label": "build Fuchs Release",
"type": "process",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/Fuchs/Fuchs.csproj",
"--configuration",
"Release",
"/p:RestoreIgnoreFailedSources=true",
"/p:RestoreConfigFile=${workspaceFolder}/NuGet.config"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{ {
"label": "gulp: all", "label": "gulp: all",
"type": "shell", "type": "shell",
+8 -4
View File
@@ -15,9 +15,10 @@
## Build & Test (workflow) ## Build & Test (workflow)
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`. - Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Build the database project whenever anything under `Fuchs_Database/` changes: `msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug` (MSBuild from the Visual Studio install, e.g. `"C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe"`; `dotnet build` cannot build the legacy SSDT project). T-SQL warnings are build errors there (ADR 0036): an unresolved or ambiguous column (SQL71502) fails the build, and a red database build is a failure like a red C# build. The working-tree SQL is **not** on any database until it is published, so this build is the only check the SQL gets before review.
- 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. - 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`. - Test: `dotnet test --project Fuchs.Tests/Fuchs.Tests.csproj -c Debug` (the root `global.json` opts `dotnet test` into the Microsoft.Testing.Platform runner that xunit.v3 needs on the .NET 10 SDK; the positional-path form no longer works).
- Always build **and** run the test suite before committing. The build emits many pre-existing analyzer/platform warnings (CA1416 etc.) — those are expected; only treat `: error` lines as failures. - Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
- Commit only when asked. Co-author trailer: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`. - Commit only when asked. Co-author trailer: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
- The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths. - The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths.
@@ -38,7 +39,7 @@
## Services & Dependency Injection ## Services & Dependency Injection
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers. - Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`). - Services: `IComService` (ProcessWeb Mailer API — contract at **https://processweb.de/api/mailer/fc**, read it and `Fuchs/Docs/Concepts/processweb-mailer-interface.md` before changing it. `ProcessWebComService` is a thin adapter over the OCORE_web `IPWMailerClientService` (`OCORE_web/comm/pwmailer/`, owns the HTTP contract; ADR 0029) with `FuchsMailerClient` overriding `ValidateMessage` for attachment limits; a send only counts as successful when the receipt's `attachmentsProcessed` matches the number sent (ADR 0028); honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async. - `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core). - Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
@@ -49,6 +50,8 @@
## Database ## Database
- Schema source of truth: **`Fuchs_Database`** SSDT project. SQL-first backend (stored procs, table types e.g. `fds__tt__bankingtransactions`, functions via OCORE — no EF Core). - Schema source of truth: **`Fuchs_Database`** SSDT project. SQL-first backend (stored procs, table types e.g. `fds__tt__bankingtransactions`, functions via OCORE — no EF Core).
- Changing a proc signature or table type → update the SSDT project **and** the calling C# together; verify every `[dbo].[…]` the backend calls exists in `Fuchs_Database`. - Changing a proc signature or table type → update the SSDT project **and** the calling C# together; verify every `[dbo].[…]` the backend calls exists in `Fuchs_Database`.
- Objects the SQL references must be **in the project** (a referenced-but-missing function only shows up as an SSDT build error now; the `ocms_*` helpers are vendored copies from the OCMSsharp `ocms_mt_database` project). `Security/Logins.sql` holds placeholder logins only so the users bind — real passwords live in Key Vault.
- `Fuchs.Tests/SqlContractTests` checks every `[dbo].[…]` named in C# against the SSDT files (exists, `EXECUTE` binds, function arg counts; ADR 0036) — a proc signature change that breaks a caller fails the test suite.
## Bank statement parsing (MT940 + CAMT) ## Bank statement parsing (MT940 + CAMT)
- `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline. - `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline.
@@ -56,8 +59,9 @@
## Observability ## Observability
- OpenTelemetry. Instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource`, one `Meter`). - OpenTelemetry. Instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource`, one `Meter`).
- For meaningful operations: start an activity, record the matching counter/histogram, and log entry/result/timing/errors via injected `ILogger<T>` using **structured** placeholders (never interpolated strings). - For meaningful operations: start an activity, record the matching counter/histogram, and log entry/result/timing/errors via injected `ILogger<T>` using **structured** placeholders (never interpolated strings) — never `{@Foo}`; plain `Microsoft.Extensions.Logging` (unlike Serilog) does not strip the `@`, it becomes a literal part of the key.
- Always collected; OTLP export opt-in via `Fuchs:Telemetry:OtlpEndpoint`. No exporters that hard-fail without a collector. - Always collected; OTLP export opt-in via `Fuchs:Telemetry:OtlpEndpoint`. No exporters that hard-fail without a collector.
- **DB log sink (ADR 0024):** every `_logger.LogWarning`/`-Error`/`-Critical` call additionally lands in `[dbo].[fds__debuglog]` via the OCORE MultiLogger SQL sink (`fds__admin_logdebug_ext`), gated by `Fuchs:Logging:SqlSink:Enabled` (default on). `RequestLogScopeMiddleware` (`Fuchs/Logging/`) attributes every log line in a request to a request id/path/authenticated user automatically. Give a log call an explicit `EventId` (its `Name` becomes the row's `CodeReference`) when the call site should be individually greppable — see `FIS_SQLOptions`, `IntranetController.Do`'s catch-all, `PdfPreviewAsync`, and the banking-upload handlers for the pattern. Do not also call the retired `Fuchs_intranet.debug_log` — one `ILogger` call is the single write path; a second explicit DB write duplicates the row.
## Testing ## Testing
- xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial: - xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial:
+8 -4
View File
@@ -15,9 +15,10 @@
## Build & Test (workflow) ## Build & Test (workflow)
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`. - Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Build the database project whenever anything under `Fuchs_Database/` changes: `msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug` (MSBuild from the Visual Studio install, e.g. `"C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe"`; `dotnet build` cannot build the legacy SSDT project). T-SQL warnings are build errors there (ADR 0036): an unresolved or ambiguous column (SQL71502) fails the build, and a red database build is a failure like a red C# build. The working-tree SQL is **not** on any database until it is published, so this build is the only check the SQL gets before review.
- 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. - 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`. - Test: `dotnet test --project Fuchs.Tests/Fuchs.Tests.csproj -c Debug` (the root `global.json` opts `dotnet test` into the Microsoft.Testing.Platform runner that xunit.v3 needs on the .NET 10 SDK; the positional-path form no longer works).
- Always build **and** run the test suite before committing. The build emits many pre-existing analyzer/platform warnings (CA1416 etc.) — those are expected; only treat `: error` lines as failures. - Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
- Commit only when asked. Co-author trailer, when requested: `Co-Authored-By: Codex <noreply@openai.com>`. - Commit only when asked. Co-author trailer, when requested: `Co-Authored-By: Codex <noreply@openai.com>`.
- The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths. - The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths.
@@ -38,7 +39,7 @@
## Services & Dependency Injection ## Services & Dependency Injection
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers. - Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`). - Services: `IComService` (ProcessWeb Mailer API — contract at **https://processweb.de/api/mailer/fc**, read it and `Fuchs/Docs/Concepts/processweb-mailer-interface.md` before changing it. `ProcessWebComService` is a thin adapter over the OCORE_web `IPWMailerClientService` (`OCORE_web/comm/pwmailer/`, owns the HTTP contract; ADR 0029) with `FuchsMailerClient` overriding `ValidateMessage` for attachment limits; a send only counts as successful when the receipt's `attachmentsProcessed` matches the number sent (ADR 0028); honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async. - `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core). - Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
@@ -49,6 +50,8 @@
## Database ## Database
- Schema source of truth: **`Fuchs_Database`** SSDT project. SQL-first backend (stored procs, table types e.g. `fds__tt__bankingtransactions`, functions via OCORE — no EF Core). - Schema source of truth: **`Fuchs_Database`** SSDT project. SQL-first backend (stored procs, table types e.g. `fds__tt__bankingtransactions`, functions via OCORE — no EF Core).
- Changing a proc signature or table type -> update the SSDT project **and** the calling C# together; verify every `[dbo].[…]` the backend calls exists in `Fuchs_Database`. - Changing a proc signature or table type -> update the SSDT project **and** the calling C# together; verify every `[dbo].[…]` the backend calls exists in `Fuchs_Database`.
- Objects the SQL references must be **in the project** (a referenced-but-missing function only shows up as an SSDT build error now; the `ocms_*` helpers are vendored copies from the OCMSsharp `ocms_mt_database` project). `Security/Logins.sql` holds placeholder logins only so the users bind — real passwords live in Key Vault.
- `Fuchs.Tests/SqlContractTests` checks every `[dbo].[…]` named in C# against the SSDT files (exists, `EXECUTE` binds, function arg counts; ADR 0036) — a proc signature change that breaks a caller fails the test suite.
## Bank statement parsing (MT940 + CAMT) ## Bank statement parsing (MT940 + CAMT)
- `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline. - `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline.
@@ -56,8 +59,9 @@
## Observability ## Observability
- OpenTelemetry. Instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource`, one `Meter`). - OpenTelemetry. Instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource`, one `Meter`).
- For meaningful operations: start an activity, record the matching counter/histogram, and log entry/result/timing/errors via injected `ILogger<T>` using **structured** placeholders (never interpolated strings). - For meaningful operations: start an activity, record the matching counter/histogram, and log entry/result/timing/errors via injected `ILogger<T>` using **structured** placeholders (never interpolated strings) — never `{@Foo}`; plain `Microsoft.Extensions.Logging` (unlike Serilog) does not strip the `@`, it becomes a literal part of the key.
- Always collected; OTLP export opt-in via `Fuchs:Telemetry:OtlpEndpoint`. No exporters that hard-fail without a collector. - Always collected; OTLP export opt-in via `Fuchs:Telemetry:OtlpEndpoint`. No exporters that hard-fail without a collector.
- **DB log sink (ADR 0024):** every `_logger.LogWarning`/`-Error`/`-Critical` call additionally lands in `[dbo].[fds__debuglog]` via the OCORE MultiLogger SQL sink (`fds__admin_logdebug_ext`), gated by `Fuchs:Logging:SqlSink:Enabled` (default on). `RequestLogScopeMiddleware` (`Fuchs/Logging/`) attributes every log line in a request to a request id/path/authenticated user automatically. Give a log call an explicit `EventId` (its `Name` becomes the row's `CodeReference`) when the call site should be individually greppable — see `FIS_SQLOptions`, `IntranetController.Do`'s catch-all, `PdfPreviewAsync`, and the banking-upload handlers for the pattern. Do not also call the retired `Fuchs_intranet.debug_log` — one `ILogger` call is the single write path; a second explicit DB write duplicates the row.
## Testing ## Testing
- xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial: - xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial:
+199
View File
@@ -172,4 +172,203 @@ public class AzureBlobStorageServiceTests
Assert.Null(result); Assert.Null(result);
Assert.True(delta >= 1, "fuchs.blobstorage.uploads.failed counter should have been incremented on a failed upload."); Assert.True(delta >= 1, "fuchs.blobstorage.uploads.failed counter should have been incremented on a failed upload.");
} }
// ── Cancellation: a caller-cancelled token (app shutdown during the startup backfill) must
// surface as OperationCanceledException, not be logged/reported as a storage failure ────
[Fact]
public async Task UploadInvoicePdfAsync_CallerCancelled_ThrowsOperationCanceled()
{
var (service, _, blob) = CreateMockedClientChain();
using var cts = new CancellationTokenSource();
cts.Cancel();
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException("The operation was canceled."));
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => svc.UploadInvoicePdfAsync("INV9", "Rechnung_INV9.pdf", new byte[] { 1 }, null, cts.Token));
}
[Fact]
public async Task UploadInvoicePdfAsync_TimeoutWithoutCallerCancel_ReturnsNull()
{
var (service, _, blob) = CreateMockedClientChain();
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException("network timeout"));
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.UploadInvoicePdfAsync("INV9", "Rechnung_INV9.pdf", new byte[] { 1 });
Assert.Null(result);
}
[Fact]
public async Task ExistsAsync_CallerCancelled_ThrowsOperationCanceled()
{
var (service, _, blob) = CreateMockedClientChain();
using var cts = new CancellationTokenSource();
cts.Cancel();
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException("The operation was canceled."));
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => svc.ExistsAsync("fuchs-invoices", "INV9", "Rechnung_INV9.pdf", cts.Token));
}
[Theory]
[InlineData(false)] // storage error
[InlineData(true)] // timeout-style cancellation while the caller's token is still live
public async Task ExistsAsync_CheckFailsWithoutCallerCancel_ReturnsFalse(bool timeout)
{
var (service, _, blob) = CreateMockedClientChain();
Exception ex = timeout ? new TaskCanceledException("timeout") : new RequestFailedException("boom");
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>())).ThrowsAsync(ex);
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Assert.False(await svc.ExistsAsync("fuchs-invoices", "INV9", "Rechnung_INV9.pdf"));
}
// ── CopyInvoicePdfToBackupAsync ────────────────────────────────────────────
[Fact]
public async Task CopyInvoicePdfToBackupAsync_NoClient_ReturnsNullWithoutThrowing()
{
var svc = new AzureBlobStorageService(client: null, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
Assert.Null(result);
}
[Fact]
public async Task CopyInvoicePdfToBackupAsync_NoExistingBlob_ReturnsNullWithoutCopying()
{
var (service, container, blob) = CreateMockedClientChain();
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(false, Mock.Of<Response>()));
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
Assert.Null(result);
blob.Verify(b => b.StartCopyFromUriAsync(It.IsAny<Uri>(), It.IsAny<BlobCopyFromUriOptions>(), It.IsAny<CancellationToken>()),
Times.Never);
blob.Verify(b => b.DeleteIfExistsAsync(
It.IsAny<DeleteSnapshotsOption>(), It.IsAny<BlobRequestConditions>(), It.IsAny<CancellationToken>()), Times.Never);
}
/// <summary>
/// Source blob exists; the backup blob accepts a server-side copy whose operation completes
/// immediately. Returns the mocks needed for verification.
/// </summary>
private static (Mock<BlobServiceClient> service, Mock<BlobClient> sourceBlob, Mock<BlobClient> backupBlob, Mock<CopyFromUriOperation> copy)
CreateCopyChain(Uri sourceUri, Uri backupUri)
{
var (service, container, sourceBlob) = CreateMockedClientChain();
sourceBlob.Setup(b => b.Uri).Returns(sourceUri);
sourceBlob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(true, Mock.Of<Response>()));
var copy = new Mock<CopyFromUriOperation>();
copy.Setup(c => c.WaitForCompletionAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(3L, Mock.Of<Response>()));
var backupBlob = new Mock<BlobClient>();
backupBlob.Setup(b => b.Uri).Returns(backupUri);
backupBlob.Setup(b => b.StartCopyFromUriAsync(It.IsAny<Uri>(), It.IsAny<BlobCopyFromUriOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(copy.Object);
container.Setup(c => c.GetBlobClient(It.Is<string>(n => n.StartsWith("INV1/backup_"))))
.Returns(backupBlob.Object);
return (service, sourceBlob, backupBlob, copy);
}
[Fact]
public async Task CopyInvoicePdfToBackupAsync_ExistingBlob_CopiesServerSideUnderBackupNameAndKeepsOriginal()
{
var sourceUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/Rechnung_INV1.pdf");
var backupUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/backup_20260917_120000_Rechnung_INV1.pdf");
var (service, sourceBlob, backupBlob, copy) = CreateCopyChain(sourceUri, backupUri);
long succeeded = 0;
using var listener = new MeterListener
{
InstrumentPublished = (inst, l) =>
{
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.backupcopies")
l.EnableMeasurementEvents(inst);
}
};
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref succeeded, value));
listener.Start();
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
Assert.Equal(backupUri, result);
// The bytes must never pass through the process: server-side copy from the source URI only.
backupBlob.Verify(b => b.StartCopyFromUriAsync(sourceUri, It.IsAny<BlobCopyFromUriOptions>(), It.IsAny<CancellationToken>()), Times.Once);
copy.Verify(c => c.WaitForCompletionAsync(It.IsAny<CancellationToken>()), Times.Once);
sourceBlob.Verify(b => b.DownloadContentAsync(It.IsAny<CancellationToken>()), Times.Never);
backupBlob.Verify(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
// The original stays: it remains the current archive until the next regeneration overwrites it.
sourceBlob.Verify(b => b.DeleteIfExistsAsync(
It.IsAny<DeleteSnapshotsOption>(), It.IsAny<BlobRequestConditions>(), It.IsAny<CancellationToken>()), Times.Never);
Assert.True(succeeded >= 1, "fuchs.blobstorage.backupcopies counter should have been incremented.");
}
[Fact]
public async Task CopyInvoicePdfToBackupAsync_CopyFails_ReturnsNullAndIncrementsFailedCounter()
{
var sourceUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/Rechnung_INV1.pdf");
var backupUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/backup_x_Rechnung_INV1.pdf");
var (service, sourceBlob, backupBlob, copy) = CreateCopyChain(sourceUri, backupUri);
copy.Setup(c => c.WaitForCompletionAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException("copy aborted"));
long failed = 0;
using var listener = new MeterListener
{
InstrumentPublished = (inst, l) =>
{
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.backupcopies.failed")
l.EnableMeasurementEvents(inst);
}
};
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref failed, value));
listener.Start();
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
Assert.Null(result);
Assert.True(failed >= 1, "fuchs.blobstorage.backupcopies.failed counter should have been incremented.");
}
[Fact]
public async Task CopyInvoicePdfToBackupAsync_ClientThrows_ReturnsNullAndIncrementsFailedCounter()
{
var (service, container, blob) = CreateMockedClientChain();
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException("simulated storage failure"));
long delta = 0;
using var listener = new MeterListener
{
InstrumentPublished = (inst, l) =>
{
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.backupcopies.failed")
l.EnableMeasurementEvents(inst);
}
};
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
listener.Start();
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV2", "Rechnung_INV2.pdf");
Assert.Null(result);
Assert.True(delta >= 1, "fuchs.blobstorage.backupcopies.failed counter should have been incremented.");
}
} }
+99
View File
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Fuchs.Controllers;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// <c>inv/datev</c> (HandleDatev) called <c>fds__getDatevExports @tgtdate, @mode, @authuser</c> positionally
/// against the signature (@tgtdate, @mode, @files, @authuser): the user id landed in <c>@files bit</c> and
/// <c>@authuser</c> was never supplied, so every call failed (8114 / 201). The command now binds by name.
/// These tests check the command against the real SSDT procedure so a future signature change is caught.
/// </summary>
public class DatevOverviewCommandTests
{
private const string ProcFile = "Stored Procedures/fds__getDatevExports.sql";
private static readonly Regex ParamDecl = new(
@"@(?<name>\w+)\s+\w+(\s*\([^)]*\))?\s*(?<def>=)?",
RegexOptions.Compiled);
private static readonly Regex ExecArg = new(
@"(?<name>@\w+)\s*(?<named>=)?",
RegexOptions.Compiled);
private static string? FindDatabaseProject()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
{
string candidate = Path.Combine(dir.FullName, "Fuchs_Database", "dbo");
if (Directory.Exists(candidate)) return candidate;
}
return null;
}
/// <summary>(name, hasDefault) of each parameter, in declaration order.</summary>
private static List<(string Name, bool HasDefault)> ProcParameters()
{
string? root = FindDatabaseProject();
if (root == null) Assert.Skip("Fuchs_Database SSDT project not present in this checkout.");
string sql = File.ReadAllText(Path.Combine(root!, ProcFile));
int start = sql.IndexOf("[fds__getDatevExports]", StringComparison.OrdinalIgnoreCase);
int end = Regex.Match(sql[start..], @"^\s*AS\s*$", RegexOptions.Multiline).Index + start;
return ParamDecl.Matches(sql[start..end])
.Select(m => ("@" + m.Groups["name"].Value, m.Groups["def"].Success))
.ToList();
}
/// <summary>
/// Binding problems of an EXECUTE text against the procedure: positional arguments, names the proc
/// does not declare, and required (no default) parameters that are not supplied.
/// </summary>
internal static List<string> BindingProblems(string execSql, List<(string Name, bool HasDefault)> procParams)
{
string args = execSql[(execSql.IndexOf(']', execSql.IndexOf("fds__getDatevExports", StringComparison.Ordinal)) + 1)..];
var problems = new List<string>();
var supplied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var part in args.TrimEnd(';', ' ').Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
var m = ExecArg.Match(part);
if (!m.Success || !m.Groups["named"].Success) { problems.Add($"positional argument '{part}'"); continue; }
string name = m.Groups["name"].Value;
if (!procParams.Any(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
problems.Add($"unknown parameter {name}");
supplied.Add(name);
}
problems.AddRange(procParams.Where(p => !p.HasDefault && !supplied.Contains(p.Name))
.Select(p => $"required parameter {p.Name} not supplied"));
return problems;
}
[Fact]
public void ProcParameters_RealSsdtFile_MatchesKnownSignature()
{
var ps = ProcParameters();
Assert.Equal(new[] { "@tgtdate", "@mode", "@files", "@authuser" }, ps.Select(p => p.Name));
Assert.Equal(new[] { false, true, false, false }, ps.Select(p => p.HasDefault));
}
[Fact]
public void DatevOverviewSql_AgainstRealProc_BindsEveryRequiredParameterByName()
=> Assert.Empty(BindingProblems(IntranetController.DatevOverviewSql, ProcParameters()));
[Theory]
[InlineData("EXECUTE [dbo].[fds__getDatevExports] @tgtdate, @mode, @authuser;")] // the former call
[InlineData("EXECUTE [dbo].[fds__getDatevExports] @tgtdate = @tgtdate, @mode = @mode, @files = 0;")] // @authuser missing
[InlineData("EXECUTE [dbo].[fds__getDatevExports] @tgtdate = @tgtdate, @files = 0, @user = @authuser;")] // unknown name
public void BindingProblems_MisboundCommand_IsReported(string execSql)
=> Assert.NotEmpty(BindingProblems(execSql, ProcParameters()));
[Fact]
public void DatevOverviewTables_NamesAllFourResultSetsInProcOrder()
// fds__getDatevExports returns header, invoice documents, bookings, debtors — the same four sets
// FdsMfr.GetDatevZip names admin/inv/buchungen/debitoren. Three names shifted every set by one.
=> Assert.Equal(new[] { "admin", "files", "invoices", "debits" }, IntranetController.DatevOverviewTables);
}
+388
View File
@@ -0,0 +1,388 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using MigraDoc.DocumentObjectModel;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OCORE.security;
using Xunit;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.Tests;
/// <summary>
/// The editor fires patches, state fetches, previews and saves concurrently for one draft token. The
/// session payload (<c>JArray</c>/<c>JObject</c>, history list) is not thread-safe, so the draft
/// services serialise every access on <c>SyncRoot</c> and hand out snapshots. Regression for the
/// production "Collection was modified" crash in <c>InvoiceSetStructure.RecomputeLevels</c>
/// (inv/dpatch, 2026-10-01). Flushes are additionally serialised on <c>FlushGate</c>: the
/// create/update decision in <c>FlushToDbAsync</c> is a read-then-write across an await, so two
/// overlapping saves of a new draft would otherwise both create a DB row.
/// </summary>
public class DraftSessionConcurrencyTests
{
/// <summary>
/// Registration double: records every call, hands out a fresh id per <b>create</b> and echoes the
/// given id for an update — like the DB, so a duplicate create is visible as a second distinct id.
/// <see cref="Block"/>, when set, parks the first registration until released, which lets a test
/// pin down the exact interleaving instead of hoping for one.
/// </summary>
private sealed class FakeInvoiceService : IInvoiceService
{
private int _ids;
public readonly ConcurrentQueue<(bool change, string invId)> Calls = new();
public TaskCompletionSource? Block;
public readonly TaskCompletionSource Entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Exception? Throw;
public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId, string userAccountId, DatabaseSecurity dbSec)
{
Calls.Enqueue((change, invId));
Entered.TrySetResult();
if (Block != null) { var b = Block; Block = null; await b.Task; }
if (Throw != null) { var ex = Throw; Throw = null; throw ex; }
string id = change ? invId : "INV" + Interlocked.Increment(ref _ids);
invoice.InvoiceRegistration = new GenericObjectDictionary(new Dictionary<string, object> { ["Id"] = id });
return invoice;
}
public Task<FdsInvoiceData> LoadInvoiceAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
public Task<Document> GenerateInvoicePdfAsync(FdsInvoiceData i, bool d) => Task.FromResult(new Document());
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData i, bool d) => throw new NotSupportedException();
public Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData i, bool d, string u, DatabaseSecurity s) => throw new NotSupportedException();
public Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData i, bool d, fds.IFdsMfr m) => throw new NotSupportedException();
}
private sealed class FakeReminderService : IReminderService
{
private int _ids;
public readonly ConcurrentQueue<(bool change, string remId)> Calls = new();
public TaskCompletionSource? Block;
public readonly TaskCompletionSource Entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
public async Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId, string userAccountId, DatabaseSecurity dbSec)
{
Calls.Enqueue((change, remId));
Entered.TrySetResult();
if (Block != null) { var b = Block; Block = null; await b.Task; }
string id = change ? remId : "REM" + Interlocked.Increment(ref _ids);
reminder.ReminderRegistration = new GenericObjectDictionary(new Dictionary<string, object> { ["Id"] = id });
return reminder;
}
public Document GenerateReminderPdf(FdsReminderData reminder, bool draft) => new();
public Task<FdsReminderData> LoadReminderAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
public Task<byte[]> RenderReminderPdfBytesAsync(FdsReminderData r, bool d) => throw new NotSupportedException();
public Task<byte[]> StoreReminderDocumentFileAsync(FdsReminderData r, bool d, string u, DatabaseSecurity s) => throw new NotSupportedException();
public Task<byte[]> GetReminderFileAsync(FdsReminderData r, bool d, fds.IFdsMfr m, string u, DatabaseSecurity s) => throw new NotSupportedException();
public Task<(System.IO.FileInfo? file, byte[]? content)> GetStoredFileAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
}
private static (InvoiceDraftEditService svc, FakeInvoiceService inv) NewInvoiceService()
{
var inv = new FakeInvoiceService();
return (new InvoiceDraftEditService(new InvoiceDraftCache(new ConfigurationBuilder().Build()), inv, NullLogger<InvoiceDraftEditService>.Instance), inv);
}
private static (ReminderDraftEditService svc, FakeReminderService rem) NewReminderService()
{
var rem = new FakeReminderService();
return (new ReminderDraftEditService(new ReminderDraftCache(new ConfigurationBuilder().Build()), rem, NullLogger<ReminderDraftEditService>.Instance), rem);
}
private static JObject InvoicePayload() => JObject.Parse(@"{
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[{'Id':'1','text':'Auftrag','itm':[{'id':'900','typ':'set','vt':0,'vv':0,'vat':'19%'},
{'id':'901','typ':'material','vt':100,'vv':19,'vat':'19%','SetItmId':'900'}],
'items':[{'id':'900','type':'set','total_net':0,'vat':'19%'},{'id':'901','type':'material','total_net':100,'vat':'19%'}]}]
}");
private static JObject ReminderPayload() => JObject.Parse(@"{
'rem':{'invid':'INV5','type':'R','invoiceid':'R2026-1','invoicedate':'2026-06-01'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','subject':'Zahlungserinnerung','amount':119,'amount_payed':0}
}");
private static JObject Block(string id, decimal net) => JObject.Parse(
"{'Id':'" + id + "','text':'Auftrag " + id + "','itm':[{'id':'i" + id + "','typ':'material','vt':" + net + ",'vv':0,'vat':'19%'}]," +
"'items':[{'id':'i" + id + "','type':'material','total_net':" + net + ",'vat':'19%'}]}");
/// <summary>Runs <paramref name="writers"/> and <paramref name="readers"/> concurrently from a common start gate.</summary>
private static async Task RunConcurrently(IEnumerable<Action> writers, IEnumerable<Action> readers)
{
using var gate = new ManualResetEventSlim(false);
var tasks = writers.Concat(readers)
.Select(a => Task.Factory.StartNew(() => { gate.Wait(); a(); }, TaskCreationOptions.LongRunning))
.ToList();
gate.Set();
await Task.WhenAll(tasks);
}
private static Task WithTimeout(Task t) => t.WaitAsync(TimeSpan.FromSeconds(10));
private static Task<T> WithTimeout<T>(Task<T> t) => t.WaitAsync(TimeSpan.FromSeconds(10));
// ── payload lock ───────────────────────────────────────────────────────────
[Theory]
[InlineData("block.replace")] // inserts new blocks → s.Req.Add (the crash: RecomputeLevels enumerating Req)
[InlineData("block.order")] // s.Req.Clear() + re-add while others enumerate
public async Task ApplyPatch_ConcurrentPatchesAndStateReads_NeverThrowAndCountEveryPatch(string target)
{
const int patches = 200;
var (svc, _) = NewInvoiceService();
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
if (target == "block.order")
for (int i = 0; i < 4; i++) svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "b" + i, Value = Block("b" + i, 10) });
int baseVersion = s.Version;
var writers = Enumerable.Range(0, patches).Select<int, Action>(i => () =>
{
var delta = target == "block.replace"
? new InvoiceDraftDelta { Target = target, Ref = "n" + i, Value = Block("n" + i, 1) }
: new InvoiceDraftDelta { Target = target, Value = new JArray(i % 2 == 0 ? new[] { "b3", "b2", "1" } : new[] { "1", "b0", "b1" }) };
Assert.NotNull(svc.ApplyPatch(s.Token, delta));
});
var readers = Enumerable.Range(0, 50).Select<int, Action>(_ => () =>
{
for (int k = 0; k < 5; k++)
{
JsonConvert.SerializeObject(svc.BuildState(s)); // what JSONAsync does after BuildState returns
_ = svc.GetHistory(s.Token).Count;
}
});
await RunConcurrently(writers, readers);
// Every applied patch bumps the version exactly once and leaves one history entry (no lost
// updates). A reorder to the order that is already current is a no-op by design, so for
// block.order fewer than `patches` may apply.
Assert.Equal(s.Version, svc.GetHistory(s.Token).Count);
if (target == "block.order")
{
Assert.InRange(s.Version, baseVersion + 1, baseVersion + patches);
Assert.Equal(5, s.Req.Count); // reorders never lose or duplicate a block
}
else
{
Assert.Equal(baseVersion + patches, s.Version);
Assert.Equal(1 + patches, s.Req.Count);
Assert.Equal(100m + patches, s.Sums.TotalNet);
}
}
[Fact]
public async Task RenderPreviewAndFlush_ConcurrentWithPatches_NeverThrowAndCreateExactlyOnce()
{
var (svc, inv) = NewInvoiceService();
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
var results = new ConcurrentBag<DraftFlushResult<FdsInvoiceData>>();
var writers = Enumerable.Range(0, 100).Select<int, Action>(i => () =>
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "n" + i, Value = Block("n" + i, 1) }));
var readers = Enumerable.Range(0, 20).Select<int, Action>(i => () =>
{
Assert.NotNull(svc.RenderPreviewAsync(s.Token).GetAwaiter().GetResult());
results.Add(svc.FlushToDbAsync(s.Token, "user1", null!).GetAwaiter().GetResult()!);
});
await RunConcurrently(writers, readers);
Assert.Equal(101, s.Req.Count);
// 20 overlapping saves of a brand-new draft: exactly one fds__createInvoice, every other save
// an update of that row — never a second orphaned invoice.
Assert.Equal(20, inv.Calls.Count);
Assert.Single(inv.Calls, c => !c.change);
Assert.Equal("INV1", s.InvId);
Assert.All(inv.Calls.Where(c => c.change), c => Assert.Equal("INV1", c.invId));
Assert.All(results, r => Assert.Equal("INV1", r.Data.Id));
Assert.Single(results, r => !r.Updated);
}
// ── flush gate ─────────────────────────────────────────────────────────────
[Fact]
public async Task FlushToDbAsync_SecondSaveOverlapsFirstRegistration_WaitsThenUpdatesInsteadOfCreating()
{
var (svc, inv) = NewInvoiceService();
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
var block = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
inv.Block = block; // the fake clears Block once it has parked on it
var first = svc.FlushToDbAsync(s.Token, "user1", null!);
await WithTimeout(inv.Entered.Task); // first save is inside the DB registration, InvId still empty
var second = svc.FlushToDbAsync(s.Token, "user1", null!);
// While the first registration is in flight the second save must not reach the registration at
// all (it would see the empty InvId and create a second row). A false pass here is possible
// only under pathological scheduling; a false failure is not.
await Task.Delay(150);
Assert.False(second.IsCompleted);
Assert.Single(inv.Calls);
block.SetResult();
var r1 = (await WithTimeout(first))!;
var r2 = (await WithTimeout(second))!;
Assert.Equal(new[] { (false, ""), (true, "INV1") }, inv.Calls.ToArray());
Assert.False(r1.Updated); Assert.Equal("INV1", r1.Data.Id);
Assert.True(r2.Updated); Assert.Equal("INV1", r2.Data.Id);
Assert.Equal("INV1", s.InvId);
}
[Fact]
public async Task FlushToDbAsync_SecondSaveCarriesPatchesMadeDuringFirst()
{
var (svc, inv) = NewInvoiceService();
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
var block = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
inv.Block = block; // the fake clears Block once it has parked on it
var first = svc.FlushToDbAsync(s.Token, "user1", null!);
await WithTimeout(inv.Entered.Task);
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = "neu@x.de" }); // edits are not blocked by a running save
var second = svc.FlushToDbAsync(s.Token, "user1", null!);
block.SetResult();
var r1 = (await WithTimeout(first))!;
var r2 = (await WithTimeout(second))!;
// The waiting save snapshots after it acquires the gate, so it persists the newer state —
// serialising never makes a save write stale data.
Assert.Equal("a@b.de", r1.Data.RawInvoiceEmail);
Assert.Equal("neu@x.de", r2.Data.RawInvoiceEmail);
Assert.True(r2.Updated);
}
[Fact]
public async Task FlushToDbAsync_RegistrationThrows_ReleasesGateAndNextSaveCreates()
{
var (svc, inv) = NewInvoiceService();
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
inv.Throw = new InvalidOperationException("db down");
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.FlushToDbAsync(s.Token, "user1", null!));
// The gate must be released by the failed save; the next one gets through and, since no id
// was committed, still runs as the create.
var r = (await WithTimeout(svc.FlushToDbAsync(s.Token, "user1", null!)))!;
Assert.False(r.Updated);
Assert.Equal("INV1", r.Data.Id);
Assert.Equal("INV1", s.InvId);
Assert.Equal(2, inv.Calls.Count);
}
[Fact]
public async Task FlushToDbAsync_UnknownToken_ReturnsNullWithoutRegistering()
{
var (svc, inv) = NewInvoiceService();
Assert.Null(await svc.FlushToDbAsync("ghost", "user1", null!));
Assert.Empty(inv.Calls);
}
[Fact]
public async Task ReminderFlushToDbAsync_SecondSaveOverlapsFirstRegistration_WaitsThenUpdatesInsteadOfCreating()
{
var (svc, rem) = NewReminderService();
var s = svc.OpenFromPayload(ReminderPayload(), "user1");
var block = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
rem.Block = block; // the fake clears Block once it has parked on it
var first = svc.FlushToDbAsync(s.Token, "user1", null!);
await WithTimeout(rem.Entered.Task);
var second = svc.FlushToDbAsync(s.Token, "user1", null!);
await Task.Delay(150);
Assert.False(second.IsCompleted);
Assert.Single(rem.Calls);
block.SetResult();
var r1 = (await WithTimeout(first))!;
var r2 = (await WithTimeout(second))!;
Assert.Equal(new[] { (false, ""), (true, "REM1") }, rem.Calls.ToArray());
Assert.False(r1.Updated); Assert.Equal("REM1", r1.Data.Id);
Assert.True(r2.Updated); Assert.Equal("REM1", r2.Data.Id);
Assert.Equal("REM1", s.RemId);
}
[Fact]
public async Task ReminderFlushToDbAsync_ManyConcurrentSaves_CreateExactlyOnce()
{
var (svc, rem) = NewReminderService();
var s = svc.OpenFromPayload(ReminderPayload(), "user1");
await RunConcurrently(
Enumerable.Range(0, 20).Select<int, Action>(_ => () => svc.FlushToDbAsync(s.Token, "user1", null!).GetAwaiter().GetResult()),
Enumerable.Empty<Action>());
Assert.Equal(20, rem.Calls.Count);
Assert.Single(rem.Calls, c => !c.change);
Assert.Equal("REM1", s.RemId);
}
// ── snapshots ──────────────────────────────────────────────────────────────
[Fact]
public void BuildState_IsSnapshot_LaterPatchDoesNotChangeReturnedPayload()
{
var (svc, _) = NewInvoiceService();
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
string before = JsonConvert.SerializeObject(svc.BuildState(s));
var state = svc.BuildState(s);
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "x", Value = Block("x", 5) });
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = "neu@x.de" });
Assert.Equal(before, JsonConvert.SerializeObject(state));
Assert.Contains("neu@x.de", JsonConvert.SerializeObject(svc.BuildState(s)));
}
[Fact]
public void GetHistory_IsSnapshot_LaterPatchDoesNotGrowReturnedList()
{
var (svc, _) = NewInvoiceService();
var s = svc.OpenFromPayload(InvoicePayload(), "user1");
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = "x@y.de" });
var history = svc.GetHistory(s.Token);
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = "z@y.de" });
Assert.Single(history);
Assert.Equal(2, svc.GetHistory(s.Token).Count);
}
[Theory]
[InlineData("")]
[InlineData("unknown-token")]
public void GetHistory_UnknownToken_ReturnsEmpty(string token)
{
Assert.Empty(NewInvoiceService().svc.GetHistory(token));
Assert.Empty(NewReminderService().svc.GetHistory(token));
}
[Fact]
public async Task ReminderApplyPatch_ConcurrentPatchesAndStateReads_NeverThrowAndCountEveryPatch()
{
const int patches = 200;
var (svc, _) = NewReminderService();
var s = svc.OpenFromPayload(ReminderPayload(), "user1");
var writers = Enumerable.Range(0, patches).Select<int, Action>(i => () =>
Assert.NotNull(svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = i % 2 == 0 ? "subject" : "amount_payed", Value = i % 2 == 0 ? "S" + i : "1" })));
var readers = Enumerable.Range(0, 50).Select<int, Action>(_ => () =>
{
for (int k = 0; k < 5; k++)
{
JsonConvert.SerializeObject(svc.BuildState(s));
_ = svc.GetHistory(s.Token).Count;
}
});
await RunConcurrently(writers, readers);
Assert.Equal(patches, s.Version);
Assert.Equal(patches, svc.GetHistory(s.Token).Count);
Assert.Equal(118m, s.Sums.AmountOpen);
}
}
+216
View File
@@ -0,0 +1,216 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using Fuchs.Controllers;
using Fuchs.intranet;
using Microsoft.Extensions.Logging;
using Xunit;
using static fds.FdsMfrClient;
namespace Fuchs.Tests;
/// <summary>
/// Covers the error-attribution fixes from the 2026-09-16 production log review and the
/// subsequent migration onto the generic OCORE MultiLogger SQL log sink (ADR 0024):
/// <see cref="FIS_SQLOptions"/> now logs every SQL error through <see cref="ILogger"/> alone
/// (which the SQL sink additionally persists to <c>fds__debuglog</c> at Warning+, so the old,
/// separate <c>Fuchs_intranet.debug_log</c> DB write was retired to avoid double-logging), with
/// an <see cref="EventId"/> carrying the exact CodeReference text the retired DB write used to.
/// The DB write itself, and <c>IntranetController.Do</c>'s full catch-all, are SQL/DI-bound and
/// not unit-tested per repo convention; these tests pin the pure logic that feeds them.
/// </summary>
/// <remarks>
/// Shares <see cref="StaticOcoreLoggerCollection"/> with <c>FdsSqlOptionsTests</c>: both save/
/// mutate/restore the process-wide static <see cref="OCORE.Logging.Logger"/> field, which would
/// otherwise race across the two classes since xUnit parallelizes different test classes by
/// default.
/// </remarks>
[Collection(StaticOcoreLoggerCollection.Name)]
public class ErrorLoggingTests
{
/// <summary>Minimal <see cref="ILogger"/> double that records every call for assertion.</summary>
private sealed class RecordingLogger : ILogger
{
public sealed record Entry(LogLevel Level, EventId EventId, Exception? Exception, string Message,
IReadOnlyDictionary<string, object?> State);
public readonly List<Entry> Entries = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
var pairs = (state as IEnumerable<KeyValuePair<string, object>>)?
.ToDictionary(kv => kv.Key, kv => (object?)kv.Value) ?? new Dictionary<string, object?>();
Entries.Add(new Entry(logLevel, eventId, exception, formatter(state, exception), pairs));
}
}
// ── FIS_SQLOptions ─────────────────────────────────────────────────────────
[Fact]
public void FisSqlOptions_OnError_LogsWithEventIdAndStructuredPlaceholders()
{
var logger = new RecordingLogger();
var context = new Dictionary<string, object> { ["fn"] = "inv", ["id"] = "invl", ["code"] = "" };
var opt = new FIS_SQLOptions(context, logger);
var ex = new TimeoutException("Das Ausführungstimeout ist abgelaufen.");
var sqlPayload = new { sql = "EXECUTE [dbo].[fds__getInvoiceList] @authuser;" };
opt.OnError!("getSQLDataSet", ex, sqlPayload);
var entry = Assert.Single(logger.Entries);
Assert.Equal(LogLevel.Error, entry.Level);
// EventId.Name preserves the exact CodeReference text the retired DB write used, so the SQL
// sink's fallback COALESCE(procedure, category, 'MultiLogger') keeps existing dashboards working.
Assert.Equal("SQL Error in getSQLDataSet", entry.EventId.Name);
Assert.Same(ex, entry.Exception);
// The failing statement and caller context are structured placeholders, not string-concatenated
// — this is what was missing before (every row's Data column was NULL).
Assert.Equal("getSQLDataSet", entry.State["Procedure"]);
Assert.Same(sqlPayload, entry.State["Sql"]);
Assert.Same(context, entry.State["Context"]);
// Plain Microsoft.Extensions.Logging (not Serilog) does not treat '@' as a destructuring
// operator; it would be kept as a literal leading character in the key. This asserts the
// placeholder names in FIS_SQLOptions are plain ("Sql"/"Context"), not "@Sql"/"@Context".
Assert.False(entry.State.ContainsKey("@Sql"));
Assert.Contains("getSQLDataSet", entry.Message);
}
[Fact]
public void FisSqlOptions_OnError_NoLoggerGiven_FallsBackToStaticOcoreLogger()
{
// Several call sites construct `new FIS_SQLOptions()` with neither logger nor context
// (Services/*.cs) — those must still reach a logger, not silently drop the error.
var fallback = new RecordingLogger();
var saved = OCORE.Logging.Logger;
OCORE.Logging.Logger = fallback;
try
{
var opt = new FIS_SQLOptions();
opt.OnError!("setSQLValue_async", new Exception("boom"), new { sql = "SELECT 1;" });
var entry = Assert.Single(fallback.Entries);
Assert.Equal("SQL Error in setSQLValue_async", entry.EventId.Name);
}
finally { OCORE.Logging.Logger = saved; }
}
[Fact]
public void FisSqlOptions_OnError_NoLoggerAndNoStaticFallback_DoesNotThrow()
{
var saved = OCORE.Logging.Logger;
OCORE.Logging.Logger = null;
try
{
var opt = new FIS_SQLOptions();
var recorded = Record.Exception(() =>
opt.OnError!("getSQLDataSet", new Exception("x"), new { sql = "SELECT 1;" }));
Assert.Null(recorded);
}
finally { OCORE.Logging.Logger = saved; }
}
// ── Response description header (500 → x-ocms-desc) ────────────────────────
[Theory]
[InlineData("Die PDF-Vorschau konnte nicht erstellt werden.", "Die%20PDF-Vorschau%20konnte%20nicht%20erstellt%20werden.")]
[InlineData("a+b", "a%2Bb")] // '+' must not survive: the client maps '+' to a space
[InlineData("Zeile1\r\nZeile2", "Zeile1%20%20Zeile2")]
[InlineData(" trimmed ", "trimmed")]
public void EncodeResponseDescription_ProducesAsciiHeaderValue_ClientDecodesToOriginal(string msg, string expected)
{
string encoded = IntranetController.EncodeResponseDescription(msg);
Assert.Equal(expected, encoded);
Assert.All(encoded, c => Assert.True(c < 128, "header must be ASCII"));
// Mirror of the client: decodeURIComponent(v.replace(/\+/g, '%20'))
Assert.Equal(msg.Replace("\r", " ").Replace("\n", " ").Trim(), Uri.UnescapeDataString(encoded.Replace("+", "%20")));
}
[Fact]
public void EncodeResponseDescription_UmlautsSurviveRoundTrip()
{
const string msg = "Zeitüberschreitung – bitte erneut versuchen";
string encoded = IntranetController.EncodeResponseDescription(msg);
Assert.All(encoded, c => Assert.True(c < 128));
Assert.Equal(msg, Uri.UnescapeDataString(encoded));
}
// ── PDF preview failure message ─────────────────────────────────────────────
[Fact]
public void PdfPreviewFailureMessage_UsesInnermostExceptionText()
{
var ex = new InvalidOperationException("outer",
new AggregateException("agg", new ApplicationException("Font 'Courier New' cannot be resolved")));
string msg = IntranetController.PdfPreviewFailureMessage(ex, includeDetail: true);
Assert.StartsWith("Die PDF-Vorschau konnte nicht erstellt werden: ", msg);
Assert.EndsWith("Font 'Courier New' cannot be resolved", msg);
Assert.DoesNotContain("outer", msg);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void PdfPreviewFailureMessage_EmptyExceptionText_FallsBackToGenericText(string text)
{
string msg = IntranetController.PdfPreviewFailureMessage(new Exception(text), includeDetail: true);
Assert.Equal("Die PDF-Vorschau konnte nicht erstellt werden.", msg);
}
[Fact]
public void PdfPreviewFailureMessage_UnauthenticatedCaller_OmitsExceptionDetail()
{
var ex = new ApplicationException("Font 'Courier New' cannot be resolved");
string msg = IntranetController.PdfPreviewFailureMessage(ex, includeDetail: false);
Assert.Equal("Die PDF-Vorschau konnte nicht erstellt werden.", msg);
Assert.DoesNotContain("Courier", msg);
}
// ── Data service: schema failure must not cascade into "FROM [dbo].[]" ────
private static DataRowCollection NavTables(params string[] names)
{
var t = new DataTable();
t.Columns.Add("tablename", typeof(string));
foreach (var n in names) t.Rows.Add(n);
return t.Rows;
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void ShapeTableNames_NoEntityTable_ReturnsEmpty_EvenWithNavTables(string entityTable)
{
var names = DatabaseSchema.ShapeTableNames(entityTable, NavTables("mfr_invoice_items"));
Assert.Empty(names);
}
[Fact]
public void ShapeTableNames_EntityTableFirst_ThenNavTables_SkippingBlanksAndDuplicates()
{
var names = DatabaseSchema.ShapeTableNames("mfr_invoice", NavTables("mfr_invoice_items", "", "mfr_invoice_items", "mfr_invoice_files"));
Assert.Equal(new[] { "mfr_invoice", "mfr_invoice_items", "mfr_invoice_files" }, names);
}
[Fact]
public void ShapeTableNames_NoNavTables_ReturnsEntityOnly()
{
var names = DatabaseSchema.ShapeTableNames("mfr_invoice", NavTables());
Assert.Equal(new[] { "mfr_invoice" }, names);
}
}
+304
View File
@@ -0,0 +1,304 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using fds;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// mfr merge truncation (2026-09-30): mfr's free-text Note (ServiceObject up to 753 chars,
/// Company up to 723) overflowed NVARCHAR(255) in the mfr__ tables / mfr__tt__ types and every
/// batch holding such a row was rejected with the column-less message 8152. Covers the
/// failure-path diagnosis in <see cref="FdsColumnWidths"/> and guards the widened SSDT schema.
/// </summary>
public class FdsColumnWidthsTests
{
private const long LongNoteServiceObjectId = 38722404355;
// Staged batch shaped like "SELECT TOP(0) [setid] = …, * FROM mfr__serviceobjects".
private static DataTable ServiceObjectBatch(params (long Id, string? Note)[] rows)
{
var t = new DataTable("mfr__serviceobjects");
t.Columns.Add("setid", typeof(string));
t.Columns.Add("Id", typeof(long));
t.Columns.Add("Name", typeof(string));
t.Columns.Add("Note", typeof(string));
t.Columns.Add("CustomValues", typeof(string));
t.Columns.Add("Version", typeof(int));
foreach (var (id, note) in rows)
t.Rows.Add("abcde", id, "Heizung Keller", (object?)note ?? DBNull.Value, "[]", 1);
return t;
}
private static Dictionary<string, int> Widths(params (string Column, int Max)[] w) =>
w.ToDictionary(x => x.Column, x => x.Max, StringComparer.OrdinalIgnoreCase);
// ── IsTruncationError ────────────────────────────────────────────────────
[Theory]
[InlineData(8152, true)] // classic "String or binary data would be truncated."
[InlineData(2628, true)] // verbose variant (compat level 150+) that names the column
[InlineData(2627, false)] // PK violation
[InlineData(515, false)] // NULL into NOT NULL
[InlineData(8672, false)] // MERGE duplicate source row
[InlineData(0, false)]
[InlineData(-2, false)] // client timeout
public void IsTruncationError_ErrorNumber_OnlyTruncationNumbersMatch(int number, bool expected)
{
Assert.Equal(expected, FdsColumnWidths.IsTruncationError(number));
}
[Fact]
public void IsTruncationError_NonSqlOrNullException_ReturnsFalse()
{
Assert.False(FdsColumnWidths.IsTruncationError((Exception?)null));
Assert.False(FdsColumnWidths.IsTruncationError(new InvalidOperationException("String or binary data would be truncated.")));
}
// ── WidthsSql ────────────────────────────────────────────────────────────
[Fact]
public void WidthsSql_IsParameterised_AndCoversTargetTableAndItsTableType()
{
Assert.Contains("@tablename", FdsColumnWidths.WidthsSql);
Assert.Contains("REPLACE(@tablename, N'__', N'__tt__')", FdsColumnWidths.WidthsSql); // same naming as mfr__updt__ merge proc
Assert.Contains("sys.table_types", FdsColumnWidths.WidthsSql);
Assert.Contains("MIN(", FdsColumnWidths.WidthsSql); // narrower of table vs. type wins
}
// ── ToWidthMap ───────────────────────────────────────────────────────────
private static DataTable WidthRows(params (object Name, object Max)[] rows)
{
var t = new DataTable();
t.Columns.Add("columnname", typeof(object));
t.Columns.Add("maxchars", typeof(object));
foreach (var (n, m) in rows) t.Rows.Add(n, m);
return t;
}
[Fact]
public void ToWidthMap_ValidRows_MapsCaseInsensitively()
{
var map = FdsColumnWidths.ToWidthMap(WidthRows(("Note", 255), ("setid", (short)5)));
Assert.Equal(255, map["note"]);
Assert.Equal(5, map["SETID"]);
}
[Fact]
public void ToWidthMap_SameColumnTwice_KeepsNarrower()
{
var map = FdsColumnWidths.ToWidthMap(WidthRows(("Note", 500), ("note", 255)));
Assert.Equal(255, Assert.Single(map).Value);
}
[Fact]
public void ToWidthMap_InvalidRows_AreSkipped()
{
var map = FdsColumnWidths.ToWidthMap(WidthRows(
("", 10), (DBNull.Value, 10), ("A", DBNull.Value), ("B", 0), ("C", -1), ("Ok", 7)));
Assert.Equal(new[] { "Ok" }, map.Keys);
}
[Fact]
public void ToWidthMap_NullOrWrongShape_ReturnsEmpty()
{
Assert.Empty(FdsColumnWidths.ToWidthMap(null));
var wrong = new DataTable();
wrong.Columns.Add("name");
wrong.Rows.Add("Note");
Assert.Empty(FdsColumnWidths.ToWidthMap(wrong));
}
// ── FindOverflows ────────────────────────────────────────────────────────
[Theory]
[InlineData(0, false)]
[InlineData(254, false)]
[InlineData(255, false)] // exactly the limit fits
[InlineData(256, true)] // one over is rejected by SQL Server
[InlineData(753, true)] // the production ServiceObject note
public void FindOverflows_NoteLengthAroundLimit_ReportsOnlyWhenOver(int length, bool overflows)
{
var batch = ServiceObjectBatch((LongNoteServiceObjectId, new string('x', length)));
var result = FdsColumnWidths.FindOverflows(batch, Widths(("Note", 255), ("Name", 255), ("setid", 5)));
if (!overflows) { Assert.Empty(result); return; }
var o = Assert.Single(result);
Assert.Equal("Note", o.Column);
Assert.Equal(255, o.MaxAllowed);
Assert.Equal(length, o.LongestLength);
Assert.Equal(1, o.RowsOver);
Assert.Equal(LongNoteServiceObjectId, o.LongestRowId);
}
[Fact]
public void FindOverflows_SeveralRowsOver_CountsThemAndPointsAtTheLongest()
{
var batch = ServiceObjectBatch((1, new string('a', 300)), (2, "short"), (3, new string('b', 539)), (4, null));
var o = Assert.Single(FdsColumnWidths.FindOverflows(batch, Widths(("Note", 255))));
Assert.Equal(2, o.RowsOver);
Assert.Equal(539, o.LongestLength);
Assert.Equal(3L, o.LongestRowId);
}
[Fact]
public void FindOverflows_SeveralColumnsOver_ReportsEachColumn()
{
var batch = ServiceObjectBatch((7, new string('n', 300)));
batch.Rows[0]["setid"] = "toolong";
var cols = FdsColumnWidths.FindOverflows(batch, Widths(("Note", 255), ("setid", 5))).Select(o => o.Column);
Assert.Equal(new[] { "setid", "Note" }, cols); // DataTable column order
}
[Fact]
public void FindOverflows_MaxOrUnknownColumns_AreNeverReported()
{
// CustomValues is NVARCHAR(MAX) → WidthsSql omits it → no entry in the map.
var batch = ServiceObjectBatch((1, new string('x', 10_000)));
batch.Rows[0]["CustomValues"] = new string('c', 100_000);
Assert.Empty(FdsColumnWidths.FindOverflows(batch, Widths(("Name", 255))));
}
[Fact]
public void FindOverflows_NonStringColumnWithWidth_IsIgnored()
{
var batch = ServiceObjectBatch((123456789012, "x"));
Assert.Empty(FdsColumnWidths.FindOverflows(batch, Widths(("Id", 2), ("Version", 0))));
}
[Fact]
public void FindOverflows_EmptyBatchOrNoWidths_ReturnsEmpty()
{
Assert.Empty(FdsColumnWidths.FindOverflows(ServiceObjectBatch(), Widths(("Note", 1))));
Assert.Empty(FdsColumnWidths.FindOverflows(ServiceObjectBatch((1, new string('x', 999))), Widths()));
}
[Fact]
public void FindOverflows_TableWithoutIdColumn_ReportsWithoutRowId()
{
var t = new DataTable("mfr__*PartnerSet");
t.Columns.Add("Property", typeof(string));
t.Rows.Add(new string('p', 60));
var o = Assert.Single(FdsColumnWidths.FindOverflows(t, Widths(("property", 50))));
Assert.Null(o.LongestRowId);
Assert.Equal("Property: 60 > 50 chars (1 row)", o.ToString());
}
// ── Describe ─────────────────────────────────────────────────────────────
[Fact]
public void Describe_NoOverflow_SaysNoneDetected()
{
Assert.Equal("none detected", FdsColumnWidths.Describe(Array.Empty<FdsColumnWidths.Overflow>()));
}
[Fact]
public void Describe_Overflows_NameColumnLengthLimitAndId()
{
var text = FdsColumnWidths.Describe(new[]
{
new FdsColumnWidths.Overflow("Note", 255, 753, 1, LongNoteServiceObjectId),
new FdsColumnWidths.Overflow("Name", 255, 300, 3, 42L),
});
Assert.Equal("Note: 753 > 255 chars (1 row, e.g. Id=38722404355); Name: 300 > 255 chars (3 rows, e.g. Id=42)", text);
}
// ── Real SSDT schema: the widened Note must stay unbounded end to end ────
private static readonly Regex ColumnDecl = new(
@"\[(?<col>[^\]]+)\]\s+\[?n?varchar\]?\s*\(\s*(?<len>\d+|max)\s*\)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static string? FindDatabaseProject()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
{
string candidate = Path.Combine(dir.FullName, "Fuchs_Database", "dbo");
if (Directory.Exists(candidate)) return candidate;
}
return null;
}
/// <summary>Declared char widths of the bounded string columns in an SSDT object (MAX omitted, like WidthsSql).</summary>
private static Dictionary<string, int> DeclaredWidths(string relativePath, string? onlyWithin = null)
{
string? root = FindDatabaseProject();
if (root == null) Assert.Skip("Fuchs_Database SSDT project not present in this checkout.");
string sql = File.ReadAllText(Path.Combine(root!, relativePath));
if (onlyWithin != null)
{
int start = sql.IndexOf(onlyWithin, StringComparison.OrdinalIgnoreCase);
Assert.True(start >= 0, $"'{onlyWithin}' not found in {relativePath}");
sql = sql[start..sql.IndexOf(");", start, StringComparison.Ordinal)];
}
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (Match m in ColumnDecl.Matches(sql))
if (!m.Groups["len"].Value.Equals("max", StringComparison.OrdinalIgnoreCase))
map[m.Groups["col"].Value] = int.Parse(m.Groups["len"].Value);
Assert.NotEmpty(map); // the regex still understands the file
return map;
}
public static IEnumerable<object[]> NoteCarriers() => new[]
{
new object[] { @"Tables\mfr__serviceobjects.sql", null! },
new object[] { @"User Defined Types\mfr__tt__serviceobjects.sql", null! },
new object[] { @"Tables\mfr__companies.sql", null! },
new object[] { @"User Defined Types\mfr__tt__companies.sql", null! },
// INSERT INTO @company SELECT cy.* FROM mfr__companies — must be as wide as the table.
new object[] { @"Stored Procedures\fds__getRequest_details.sql", "DECLARE @company TABLE" },
new object[] { @"Stored Procedures\fds__prepStorno_recreate.sql", "DECLARE @company TABLE" },
};
[Theory]
[MemberData(nameof(NoteCarriers))]
public void SsdtSchema_NoteColumn_IsUnbounded(string relativePath, string? onlyWithin)
{
var widths = DeclaredWidths(relativePath.Replace('\\', Path.DirectorySeparatorChar), onlyWithin);
Assert.False(widths.ContainsKey("Note"), $"{relativePath}: [Note] is bounded ({(widths.TryGetValue("Note", out int w) ? w : 0)}) — mfr notes exceed 255 chars");
}
[Theory]
[InlineData(@"Tables\mfr__serviceobjects.sql", @"User Defined Types\mfr__tt__serviceobjects.sql")]
[InlineData(@"Tables\mfr__companies.sql", @"User Defined Types\mfr__tt__companies.sql")]
public void SsdtSchema_ProductionLongNote_FitsTableAndTableType(string table, string type)
{
var widths = DeclaredWidths(table.Replace('\\', Path.DirectorySeparatorChar));
foreach (var kv in DeclaredWidths(type.Replace('\\', Path.DirectorySeparatorChar)))
widths[kv.Key] = widths.TryGetValue(kv.Key, out int w) ? Math.Min(w, kv.Value) : kv.Value;
var batch = ServiceObjectBatch((LongNoteServiceObjectId, new string('x', 753)));
Assert.Empty(FdsColumnWidths.FindOverflows(batch, widths));
}
[Fact]
public void SsdtSchema_OldNarrowNote_WouldHaveBeenReported()
{
// Failing-path counterpart: with the pre-fix NVARCHAR(255) the same batch is diagnosed.
var widths = DeclaredWidths(Path.Combine("User Defined Types", "mfr__tt__serviceobjects.sql"));
widths["Note"] = 255;
var batch = ServiceObjectBatch((LongNoteServiceObjectId, new string('x', 753)));
Assert.Equal("Note: 753 > 255 chars (1 row, e.g. Id=38722404355)",
FdsColumnWidths.Describe(FdsColumnWidths.FindOverflows(batch, widths)));
}
}
+98
View File
@@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using System.Linq;
using fds;
using Microsoft.Extensions.Logging;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Covers <see cref="FdsSqlOptions"/> (the MFR-sync worker's equivalent of
/// <c>Fuchs.intranet.FIS_SQLOptions</c>): after ADR 0024, it must log a SQL error through
/// <see cref="ILogger"/> alone — the original 2026-09-16 log review's deadlock/timeout rows came
/// from this exact class calling both <see cref="ILogger"/> and a direct
/// <c>EXECUTE [dbo].[fds__admin_logdebug]</c>, which would double-write every row once the
/// worker's ILogger calls also reach the SQL log sink (hosted in the same process as the web app,
/// ADR 0010).
/// </summary>
/// <remarks>
/// Shares <see cref="StaticOcoreLoggerCollection"/> with <c>ErrorLoggingTests</c>: both save/
/// mutate/restore the process-wide static <see cref="OCORE.Logging.Logger"/> field, which would
/// otherwise race across the two classes since xUnit parallelizes different test classes by
/// default.
/// </remarks>
[Collection(StaticOcoreLoggerCollection.Name)]
public class FdsSqlOptionsTests
{
private sealed class RecordingLogger : ILogger
{
public sealed record Entry(LogLevel Level, EventId EventId, Exception? Exception, string Message,
IReadOnlyDictionary<string, object?> State);
public readonly List<Entry> Entries = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
var pairs = (state as IEnumerable<KeyValuePair<string, object>>)?
.ToDictionary(kv => kv.Key, kv => (object?)kv.Value) ?? new Dictionary<string, object?>();
Entries.Add(new Entry(logLevel, eventId, exception, formatter(state, exception), pairs));
}
}
[Fact]
public void OnError_LogsOnce_WithProcedureAsEventIdName_NotPrefixed()
{
var logger = new RecordingLogger();
var dic = new Dictionary<string, object> { ["uid"] = "abc" };
var opt = new FdsSqlOptions(dic, logger);
var ex = new TimeoutException("deadlock victim");
opt.OnError!("mfr__getSchema", ex, new { sql = "EXECUTE [dbo].[mfr__getSchema] 'table', @tgttype;" });
var entry = Assert.Single(logger.Entries);
Assert.Equal(LogLevel.Error, entry.Level);
// Unprefixed: the direct DebugLog() call this replaced passed `procedure` as-is.
Assert.Equal("mfr__getSchema", entry.EventId.Name);
Assert.Same(ex, entry.Exception);
Assert.Equal("mfr__getSchema", entry.State["Procedure"]);
Assert.Same(dic, entry.State["CtrlInfo"]);
}
[Fact]
public void OnError_NoLoggerGiven_FallsBackToStaticOcoreLogger()
{
var fallback = new RecordingLogger();
var saved = OCORE.Logging.Logger;
OCORE.Logging.Logger = fallback;
try
{
var opt = new FdsSqlOptions();
opt.OnError!("fds__getUpdateableRequests", new Exception("boom"), new { sql = "SELECT 1;" });
var entry = Assert.Single(fallback.Entries);
Assert.Equal("fds__getUpdateableRequests", entry.EventId.Name);
}
finally { OCORE.Logging.Logger = saved; }
}
[Fact]
public void OnError_NoLoggerAndNoStaticFallback_DoesNotThrow()
{
var saved = OCORE.Logging.Logger;
OCORE.Logging.Logger = null;
try
{
var opt = new FdsSqlOptions();
var recorded = Record.Exception(() =>
opt.OnError!("mfr__getSchema", new Exception("x"), new { sql = "SELECT 1;" }));
Assert.Null(recorded);
}
finally { OCORE.Logging.Logger = saved; }
}
}
+27
View File
@@ -0,0 +1,27 @@
using System.Runtime.CompilerServices;
namespace Fuchs.Tests;
/// <summary>
/// Installs PdfSharp's global font resolver once, before any test runs.
/// </summary>
/// <remarks>
/// <see cref="PdfSharp.Fonts.GlobalFontSettings.FontResolver"/> is process-wide and, once used,
/// cannot be swapped — PdfSharp throws "You must not change font resolver after it was once used".
/// Two independent installers exist in this solution: <c>FuchsPdf.EnsureFontResolver</c> (installs
/// <c>OCOREFontResolver</c>, used by <see cref="PdfPipelineTests"/>) and eRechnungLib's
/// <c>SystemFontResolver.EnsureInstalled</c> (installs itself only when none is set yet, used
/// internally by the parameterless <c>ToZugferd(profile)</c> overload some tests call, e.g.
/// <see cref="ERechnungMapperTests"/>). Production code never hits that second path — it always
/// supplies its own rendered PDF (ADR 0012) — so this conflict is test-process-only, but whichever
/// installer's first render happened to run first (an xUnit test-execution-order accident, not a
/// property of the code under test) would win for every other test in the process. Installing
/// OCOREFontResolver here, before test discovery hands control to any test method, makes the
/// outcome deterministic instead of order-dependent.
/// </remarks>
internal static class FontResolverTestBootstrap
{
[ModuleInitializer]
internal static void Install() =>
PdfSharp.Fonts.GlobalFontSettings.FontResolver = new OCORE_web_pdf.pdf.OCOREFontResolver();
}
+2 -2
View File
@@ -15,8 +15,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="Moq" Version="4.21.0" />
<PackageReference Include="coverlet.collector" Version="10.0.1"> <PackageReference Include="coverlet.collector" Version="10.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
+132
View File
@@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using System.Linq;
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 OCORE.web.pwmailer;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Covers the Fuchs override of <see cref="PWMailerClientService.ValidateMessage"/>: the Mailer
/// contract's attachment limits (count, per-file and per-message size, forbidden extensions) are
/// enforced before any HTTP request, on top of the base rules (required fields, empty file,
/// missing extension), and a compliant message still goes out. The transport is a scripted
/// handler that fails the test if it is reached when it must not be.
/// </summary>
public class FuchsMailerClientTests
{
private sealed class ScriptedHandler : HttpMessageHandler
{
public int Calls;
public string Body = "{\"comStackID\":\"11111111-2222-3333-4444-555555555555\",\"attachmentsReceived\":1,\"attachmentsProcessed\":1}";
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Calls++;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(Body) });
}
}
private sealed class Factory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public Factory(HttpMessageHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
}
private static (FuchsMailerClient client, ScriptedHandler handler) Make(MailerAttachmentLimits? limits = null)
{
var handler = new ScriptedHandler();
var options = Options.Create(new PWMailerClientOptions
{
BaseUrl = "https://mailer.test/api/mailer", Account = "acct", Token = "tok",
ServerId = "11111111-2222-3333-4444-555555555555", RetryDelayMilliseconds = 0
});
var settings = Options.Create(new ProcessWebComSettings { Attachments = limits ?? new MailerAttachmentLimits() });
return (new FuchsMailerClient(options, settings, new Factory(handler), NullLogger<PWMailerClientService>.Instance), handler);
}
private static PWMailerMessage Message(params PWMailerAttachment[] attachments)
{
var m = new PWMailerMessage { To = "a@example.com", Subject = "Hi", Body = "<p>x</p>", Reference = "R1" };
m.Attachments.AddRange(attachments);
return m;
}
public static IEnumerable<object[]> LimitViolations()
{
yield return [new PWMailerAttachment[] { new("run.EXE", [1]) }, "forbidden extension (.exe)"];
yield return [new PWMailerAttachment[] { new("script.js", [1]) }, "forbidden extension (.js)"];
yield return [Enumerable.Range(1, 11).Select(i => new PWMailerAttachment($"f{i}.pdf", [1])).ToArray(), "Too many attachments: 11 (max 10)"];
}
[Theory]
[MemberData(nameof(LimitViolations))]
public async Task PushAsync_AttachmentViolatesContractLimits_RefusedBeforeSending(PWMailerAttachment[] attachments, string expected)
{
var (client, handler) = Make();
var result = await client.PushAsync(Message(attachments));
Assert.False(result.Success);
Assert.Equal(0, result.StatusCode);
Assert.Equal(0, result.Attempts);
Assert.Contains(expected, result.Error);
Assert.Equal(0, handler.Calls);
}
[Fact]
public async Task PushAsync_PerFileAndPerMessageSizeLimits_AreEnforcedFromSettings()
{
var (client, handler) = Make(new MailerAttachmentLimits { MaxFileBytes = 100, MaxMessageBytes = 150, MaxCount = 10 });
var tooBig = await client.PushAsync(Message(new PWMailerAttachment("a.pdf", new byte[101])));
Assert.Contains("'a.pdf' is 101 bytes (max 100)", tooBig.Error);
var total = await client.PushAsync(Message(new PWMailerAttachment("a.pdf", new byte[100]), new PWMailerAttachment("b.pdf", new byte[51])));
Assert.Contains("total 151 bytes (max 150", total.Error);
Assert.Equal(0, handler.Calls);
}
[Theory]
[InlineData("leer.pdf", new byte[0], "is empty")]
[InlineData("noext", new byte[] { 1 }, "no file extension")]
public async Task PushAsync_BaseContractRules_StillAppliedThroughOverride(string name, byte[] bytes, string expected)
{
var (client, handler) = Make();
var result = await client.PushAsync(Message(new PWMailerAttachment(name, bytes)));
Assert.False(result.Success);
Assert.Contains(expected, result.Error);
Assert.Equal(0, handler.Calls);
}
[Theory]
[InlineData("Rechnung.pdf")]
[InlineData("export.CSV")]
[InlineData("archive.7z")]
public async Task PushAsync_AllowedAttachment_IsSent(string name)
{
var (client, handler) = Make();
var result = await client.PushAsync(Message(new PWMailerAttachment(name, [1, 2])));
Assert.True(result.Success, result.Error);
Assert.Equal(1, handler.Calls);
Assert.Equal(1, result.Value!.AttachmentsProcessed);
}
[Fact]
public void MailerAttachmentPolicy_NoAttachments_NoViolation()
{
Assert.Empty(MailerAttachmentPolicy.Validate(Array.Empty<PWMailerAttachment>(), new MailerAttachmentLimits()));
}
}
+165
View File
@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Globalization;
using System.Linq;
using Fuchs.intranet;
using Microsoft.Data.SqlClient;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Invoice amounts reach the procs as exact, cent-rounded decimals, independent of the server
/// culture. Regression for the Stornorechnung save failure: the VAT amount was formatted
/// invariantly ("-615.3757999999999", unrounded double from a reloaded Storno line) and re-parsed
/// under de-DE as -6153757999999999 → "Error converting data type float to numeric".
/// </summary>
public class InvoiceAmountParamTests : IDisposable
{
private readonly CultureInfo _culture = CultureInfo.CurrentCulture;
public void Dispose() => CultureInfo.CurrentCulture = _culture;
private static readonly string[] AmountParams =
{ "@InvoiceBalance", "@InvoiceBalance_net", "@InvoiceVAT_net1", "@InvoiceService_net", "@InvoiceService_VAT" };
/// <summary>The exact shape InvoiceDraftEditService.BuildFdsData produces: decimal sums, VAT map by rate.</summary>
private static FdsInvoiceData StornoDraft(object vatAmount) => new(new JObject
{
["admin"] = new JObject { ["type"] = "c", ["customerid"] = "7" },
["new"] = new JObject { ["title"] = "Stornorechnung", ["total_net"] = -3238.82m, ["total_gross"] = -3854.20m },
["sms"] = new JObject
{
["tscn"] = -1234.56m, ["tscvat"] = -234.57m,
["vat"] = new JObject { ["19"] = JToken.FromObject(vatAmount) }
}
});
private static decimal Val(List<SqlParameter> pl, string name)
{
var p = pl.Single(x => x.ParameterName == name);
Assert.Equal(SqlDbType.Decimal, p.SqlDbType);
return Assert.IsType<decimal>(p.Value);
}
public static TheoryData<string, object> CulturesAndVat => new()
{
{ "de-DE", -615.38m },
{ "en-US", -615.38m },
{ "de-DE", -615.3757999999999 }, // unrounded double from a reloaded Storno line (net * vat * 0.01)
{ "en-US", -615.3757999999999 },
{ "de-DE", -615.3800000000001 },
{ "", -615.38m }, // invariant
};
[Theory]
[MemberData(nameof(CulturesAndVat))]
public void BuildInvoiceParams_StornoDraft_AmountsExactInEveryCulture(string culture, object vatAmount)
{
CultureInfo.CurrentCulture = new CultureInfo(culture);
var pl = StornoDraft(vatAmount).BuildInvoiceParams(change: true, invId: "abc");
Assert.Equal(-3854.20m, Val(pl, "@InvoiceBalance"));
Assert.Equal(-3238.82m, Val(pl, "@InvoiceBalance_net"));
Assert.Equal(-615.38m, Val(pl, "@InvoiceVAT_net1"));
Assert.Equal(-1234.56m, Val(pl, "@InvoiceService_net"));
Assert.Equal(-234.57m, Val(pl, "@InvoiceService_VAT"));
}
[Fact]
public void BuildInvoiceParams_AmountParams_MatchProcPrecisionAndScale()
{
var pl = StornoDraft(-615.38m).BuildInvoiceParams(change: false, invId: "");
foreach (var name in AmountParams)
{
var p = pl.Single(x => x.ParameterName == name);
Assert.Equal(10, p.Precision);
// fds__setInvoice/fds__createInvoice: numeric(10,3); fds__createInvoice_Details: numeric(10,2).
Assert.Equal(name.StartsWith("@InvoiceService_") ? 2 : 3, p.Scale);
}
}
[Theory]
[InlineData(-10_000_000.00)]
[InlineData(10_000_000.00)]
[InlineData(-6153757999999999.0)]
public void BuildInvoiceParams_AmountOutOfProcRange_Throws(double vat)
{
var ex = Assert.Throws<InvoiceAmountOutOfRangeException>(() =>
StornoDraft(vat).BuildInvoiceParams(change: true, invId: "abc"));
Assert.Equal("@InvoiceVAT_net1", ex.Parameter);
}
[Theory]
[InlineData(9_999_999.99)]
[InlineData(-9_999_999.99)]
[InlineData(0.0)]
public void BuildInvoiceParams_AmountAtProcRangeBoundary_Accepted(double vat)
{
var pl = StornoDraft(vat).BuildInvoiceParams(change: true, invId: "abc");
Assert.Equal((decimal)vat, Val(pl, "@InvoiceVAT_net1"));
}
[Fact]
public void BuildInvoiceParams_MissingAmounts_KeepPreviousNullAndZeroSemantics()
{
var noSms = new FdsInvoiceData(new JObject { ["admin"] = new JObject(), ["new"] = new JObject() })
.BuildInvoiceParams(change: false, invId: "");
Assert.Equal(DBNull.Value, noSms.Single(p => p.ParameterName == "@InvoiceBalance").Value);
Assert.Equal(0m, noSms.Single(p => p.ParameterName == "@InvoiceService_net").Value); // no sms block → 0
Assert.Equal(0m, noSms.Single(p => p.ParameterName == "@InvoiceVAT_net1").Value); // HighestVat → "0"
var emptySms = new FdsInvoiceData(new JObject { ["new"] = new JObject(), ["sms"] = new JObject() })
.BuildInvoiceParams(change: false, invId: "");
Assert.Equal(DBNull.Value, emptySms.Single(p => p.ParameterName == "@InvoiceService_net").Value); // key missing → NULL
// No "new" block at all: the legacy SQL_Float(nz(...) ?? "0") sent 0, not NULL.
var noNew = new FdsInvoiceData(new JObject { ["admin"] = new JObject(), ["sms"] = new JObject() })
.BuildInvoiceParams(change: false, invId: "");
Assert.Equal(0m, noNew.Single(p => p.ParameterName == "@InvoiceBalance").Value);
Assert.Equal(0m, noNew.Single(p => p.ParameterName == "@InvoiceBalance_net").Value);
}
[Theory]
[InlineData("-19.95", "-19.95")]
[InlineData("-19,95", "-19.95")]
[InlineData("-1234.5600000000002", "-1234.56")]
[InlineData("-615.3757999999999", "-615.38")]
[InlineData("1234,565", "1234.57")] // rounded half away from zero
[InlineData("-1234,565", "-1234.57")]
[InlineData("-1.5E-05", "0")]
[InlineData(" 42 ", "42")]
public void ParseAmount_Strings_ParsedCultureIndependentlyAndRoundedToCents(string raw, string expected)
{
foreach (var c in new[] { "de-DE", "en-US" })
{
CultureInfo.CurrentCulture = new CultureInfo(c);
Assert.Equal(decimal.Parse(expected, CultureInfo.InvariantCulture), FdsInvoiceData.ParseAmount(raw));
}
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("NaN")]
[InlineData("abc")]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void ParseAmount_EmptyOrInvalid_IsNull(object? raw) =>
Assert.Null(FdsInvoiceData.ParseAmount(raw));
[Fact]
public void ParseAmount_NumbersAndJValues_TakenAsIs()
{
Assert.Equal(-3238.82m, FdsInvoiceData.ParseAmount(-3238.82m));
Assert.Equal(-3238.82m, FdsInvoiceData.ParseAmount(-3238.82));
Assert.Equal(-12m, FdsInvoiceData.ParseAmount(-12));
Assert.Equal(-3238.82m, FdsInvoiceData.ParseAmount(new JValue(-3238.82m)));
Assert.Null(FdsInvoiceData.ParseAmount(JValue.CreateNull()));
}
}
+105
View File
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using Fuchs.intranet;
using Xunit;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.Tests;
/// <summary>
/// ADR 0032: one invoice date for the PDF ("Rechnungsdatum") and the e-invoice issue date —
/// DateFinalized (incl. backdated), else DateCreated — converted from the stored UTC to the German
/// calendar day; plus the backdate request/response parsing used by inv/dpreview and req/sconf.
/// </summary>
public class InvoiceDatesTests
{
private static GenericObjectDictionary Reg(object? finalized, object? created) => new(new Dictionary<string, object?>
{
["DateFinalized"] = finalized,
["DateCreated"] = created
});
[Fact]
public void IssueDate_FinalizedAndCreated_PrefersDateFinalized()
=> Assert.Equal(new DateOnly(2026, 9, 30),
InvoiceDates.IssueDate(Reg(new DateTime(2026, 9, 30, 12, 0, 0), new DateTime(2026, 9, 2, 8, 0, 0))));
[Fact]
public void IssueDate_DraftWithoutFinalized_FallsBackToDateCreated()
=> Assert.Equal(new DateOnly(2026, 9, 2),
InvoiceDates.IssueDate(Reg(DBNull.Value, new DateTime(2026, 9, 2, 8, 0, 0))));
[Fact]
public void IssueDate_NoRegistration_UsesProvidedUtcNow()
=> Assert.Equal(new DateOnly(2026, 3, 1),
InvoiceDates.IssueDate(null, new DateTime(2026, 3, 1, 10, 0, 0, DateTimeKind.Utc)));
[Theory]
// UTC late evening is already the next German day (summer +2h, winter +1h)
[InlineData("2026-09-30T22:30:00", "2026-10-01")]
[InlineData("2026-09-30T21:59:00", "2026-09-30")]
[InlineData("2026-01-31T23:30:00", "2026-02-01")]
[InlineData("2026-01-31T22:59:00", "2026-01-31")]
// backdated invoices are stored at 12:00 UTC and stay on their day
[InlineData("2026-08-31T12:00:00", "2026-08-31")]
[InlineData("2025-12-31T12:00:00", "2025-12-31")]
public void IssueDate_StoredUtc_ReturnsGermanCalendarDay(string storedUtc, string expected)
=> Assert.Equal(DateOnly.Parse(expected),
InvoiceDates.IssueDate(Reg(DateTime.Parse(storedUtc), null)));
[Theory]
[InlineData("2026-09-30", 2026, 9, 30)]
[InlineData(" 2025-12-31 ", 2025, 12, 31)]
public void ParseBackdate_IsoDate_ReturnsDate(string iso, int y, int m, int d)
=> Assert.Equal(new DateOnly(y, m, d), InvoiceDates.ParseBackdate(iso));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("NULL")]
[InlineData("30.09.2026")]
[InlineData("2026-02-30")]
public void ParseBackdate_EmptyOrInvalid_ReturnsNull(string? iso)
=> Assert.Null(InvoiceDates.ParseBackdate(iso));
[Theory]
[InlineData("1", true)]
[InlineData("true", true)]
[InlineData("TRUE", true)]
[InlineData("on", true)]
[InlineData("0", false)]
[InlineData("false", false)]
[InlineData("", false)]
[InlineData(null, false)]
[InlineData("yes please", false)]
public void IsBackdateRequested_FormValue_ParsesStrictly(string? value, bool expected)
=> Assert.Equal(expected, InvoiceDates.IsBackdateRequested(value));
// fds__setInvoiceFinal result → what req/sconf tells the user. Only 60001 is a backdate rejection;
// a 0-row result is the already-final/sent guard (double submit) and must not invite a duplicate.
[Theory]
[InlineData("", null, 1, InvoiceFinalizeOutcome.Finalized)]
[InlineData(null, null, 1, InvoiceFinalizeOutcome.Finalized)]
[InlineData("SQL_exception: backdating not allowed", 60001, 0, InvoiceFinalizeOutcome.BackdateNotAllowed)]
[InlineData("SQL_exception: not authorized", 60000, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("SQL_exception: lock not acquired", 60002, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("SQL_exception: Cannot insert NULL", 515, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("SQL_exception: timeout", null, 0, InvoiceFinalizeOutcome.Error)]
[InlineData("", null, 0, InvoiceFinalizeOutcome.AlreadyFinal)]
[InlineData(null, null, 0, InvoiceFinalizeOutcome.AlreadyFinal)]
public void ClassifyFinalizeResult_SqlOutcome_MapsToOutcome(string? exception, int? errorNumber, int rows, InvoiceFinalizeOutcome expected)
=> Assert.Equal(expected, InvoiceDates.ClassifyFinalizeResult(exception, errorNumber, rows));
[Fact]
public void ClassifyFinalizeResult_ErrorNumber60001WithoutExceptionText_IsNotARejection()
// the number alone never arrives without a message; a row with no exception is a success
=> Assert.Equal(InvoiceFinalizeOutcome.Finalized, InvoiceDates.ClassifyFinalizeResult("", 60001, 1));
[Theory]
[InlineData(InvoiceFinalizeOutcome.Finalized, "ok")]
[InlineData(InvoiceFinalizeOutcome.BackdateNotAllowed, "rejected")]
[InlineData(InvoiceFinalizeOutcome.AlreadyFinal, "already_final")]
[InlineData(InvoiceFinalizeOutcome.Error, "error")]
public void OutcomeTag_EveryOutcome_HasDistinctTelemetryTag(InvoiceFinalizeOutcome outcome, string expected)
=> Assert.Equal(expected, InvoiceDates.OutcomeTag(outcome));
}
+4 -2
View File
@@ -169,7 +169,8 @@ public class InvoiceDraftServiceTests
var result = await svc.FlushToDbAsync(s.Token, "user1", null!); var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
Assert.NotNull(result); Assert.NotNull(result);
Assert.Equal("INV42", result!.Id); Assert.Equal("INV42", result!.Data.Id);
Assert.False(result.Updated);
Assert.False(inv.LastChange); // new draft (no prior InvId) → create, not update Assert.False(inv.LastChange); // new draft (no prior InvId) → create, not update
Assert.Equal("INV42", svc.Get(s.Token)!.InvId); Assert.Equal("INV42", svc.Get(s.Token)!.InvId);
@@ -680,9 +681,10 @@ public class InvoiceDraftServiceTests
payload["invid"] = "INV7"; payload["invid"] = "INV7";
var s = svc.OpenFromPayload(payload, "user1"); var s = svc.OpenFromPayload(payload, "user1");
await svc.FlushToDbAsync(s.Token, "user1", null!); var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
Assert.True(inv.LastChange); // prior InvId → update path Assert.True(inv.LastChange); // prior InvId → update path
Assert.True(result!.Updated);
} }
[Fact] [Fact]
@@ -0,0 +1,177 @@
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.Logging;
using Moq;
using OCORE.security;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Covers <see cref="InvoiceFileBackupService.BackupAndClearAsync"/>: the invoice-not-found
/// short-circuit, that a blob backup-rename failure never blocks the DB-side backup/clear (best
/// effort per Docs/Decisions/0027), and that the DB call failing is reported without throwing.
/// No SQL Server: the DB-clear path uses an unconfigured connection so the write fails fast inside
/// the OCORE helper, mirroring InvoiceServiceStoreFileTests.
/// </summary>
public class InvoiceFileBackupServiceTests
{
private sealed class RecordingLogger : ILogger<InvoiceFileBackupService>
{
public sealed record Entry(LogLevel Level, Exception? Exception, string Message);
public readonly List<Entry> Entries = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add(new Entry(logLevel, exception, formatter(state, exception)));
}
// A refused local port: the write fails inside the OCORE helper's own try/catch (returning an
// Exception string) instead of hanging on a connect timeout.
private const string UnreachableConnectionString =
"Server=127.0.0.1,1;Database=fuchs;User Id=u;Password=p;Connect Timeout=1;TrustServerCertificate=True";
private static FdsInvoiceData Invoice(string id = "W7VAL", bool isFinal = true, bool isExternal = false, bool hasFile = true) => new()
{
InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>
{
["Id"] = id,
["InvoiceId"] = "R2026-0258",
["DocumentName"] = $"Rechnung_{id}.pdf",
["IsFinal"] = isFinal,
["IsExternal"] = isExternal,
["hasFile"] = hasFile,
}),
IsDraft = !isFinal,
};
private static (InvoiceFileBackupService svc, RecordingLogger log, Mock<IInvoiceService> invoices, Mock<IBlobStorageService> blob)
MakeService(FdsInvoiceData? invoice, string? connectionString = null)
{
var log = new RecordingLogger();
var invoices = new Mock<IInvoiceService>();
invoices.Setup(i => i.LoadInvoiceAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DatabaseSecurity>()))
.ReturnsAsync(invoice ?? new FdsInvoiceData());
var blob = new Mock<IBlobStorageService>();
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:fuchs_fds_ConnectionString"] = connectionString
}).Build();
var svc = new InvoiceFileBackupService(new Fuchs_intranet(config), invoices.Object, blob.Object, log);
return (svc, log, invoices, blob);
}
// LoadInvoiceAsync never yields a null registration for an unknown id — it yields an empty
// dictionary (FirstRow.toObjectDictionary()), i.e. an invoice whose Id is "". Both shapes must
// be recognised as "not found" and never fall through to the IsDraft check as "not_final".
public static IEnumerable<object[]> NotFoundShapes() =>
[
[ new FdsInvoiceData() ],
[ new FdsInvoiceData { InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>()) } ],
];
[Theory]
[MemberData(nameof(NotFoundShapes))]
public async Task BackupAndClearAsync_InvoiceNotFound_ReturnsFailureWithoutTouchingBlobOrDb(FdsInvoiceData loaded)
{
var (svc, log, invoices, blob) = MakeService(loaded);
var result = await svc.BackupAndClearAsync("MISSING", "user1", new DatabaseSecurity("user1", null));
Assert.False(result.Success);
Assert.Equal("invoice_not_found", result.Reason);
Assert.Null(result.BlobBackupUri);
blob.Verify(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
var entry = Assert.Single(log.Entries);
Assert.Equal(LogLevel.Warning, entry.Level);
Assert.Contains("not found", entry.Message);
}
// ADR 0025's regeneration precondition is load-bearing: clearing a draft's file would let the
// next read persist a draft render that then blocks the real final PDF forever; an external
// (MFR) document can't be regenerated at all. Both must be refused before anything is touched.
[Theory]
[InlineData(false, false, "not_final")]
[InlineData(true, true, "external")]
[InlineData(false, true, "not_final")]
public async Task BackupAndClearAsync_DraftOrExternalInvoice_RefusesWithoutTouchingBlobOrDb(
bool isFinal, bool isExternal, string expectedReason)
{
var (svc, log, invoices, blob) = MakeService(Invoice(isFinal: isFinal, isExternal: isExternal), UnreachableConnectionString);
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
Assert.False(result.Success);
Assert.Equal(expectedReason, result.Reason);
Assert.Null(result.BlobBackupUri);
blob.Verify(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
var entry = Assert.Single(log.Entries);
Assert.Equal(LogLevel.Warning, entry.Level);
Assert.Contains("refused", entry.Message);
Assert.DoesNotContain(log.Entries, e => e.Level == LogLevel.Error);
}
[Fact]
public async Task BackupAndClearAsync_NoStoredFile_ReturnsNoFileWithoutCopyingBlobOrTouchingDb()
{
// A repeated click on an already-cleared invoice must not leave another backup_* blob
// behind: fds__getInvoice's hasFile settles it before the copy and before the proc.
var (svc, log, invoices, blob) = MakeService(Invoice(hasFile: false), UnreachableConnectionString);
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
Assert.False(result.Success);
Assert.Equal("no_file", result.Reason);
Assert.Null(result.BlobBackupUri);
blob.Verify(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
Assert.DoesNotContain(log.Entries, e => e.Level == LogLevel.Error); // the proc was never called
Assert.Contains(log.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("no file stored"));
}
[Fact]
public async Task BackupAndClearAsync_BlobCopySucceeds_DbFails_ReportsDbErrorWithBackupUri()
{
// The backup copy is taken BEFORE the DB clear (so no regeneration can overwrite the
// original first) and the original blob is kept; a DB failure (here: no real SQL Server)
// therefore leaves the archive intact plus a spare copy, which the result still reports.
var backupUri = new Uri("https://test.blob.core.windows.net/invoices/W7VAL/backup_x.pdf");
var (svc, log, invoices, blob) = MakeService(Invoice(), UnreachableConnectionString);
blob.Setup(b => b.CopyInvoicePdfToBackupAsync("W7VAL", "Rechnung_W7VAL.pdf", It.IsAny<CancellationToken>()))
.ReturnsAsync(backupUri);
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
Assert.False(result.Success);
Assert.Equal("db_error", result.Reason);
Assert.Equal(backupUri, result.BlobBackupUri);
blob.Verify(b => b.CopyInvoicePdfToBackupAsync("W7VAL", "Rechnung_W7VAL.pdf", It.IsAny<CancellationToken>()), Times.Once);
Assert.Contains(log.Entries, e => e.Level == LogLevel.Error && e.Message.Contains("fds__admin_backupAndClearInvoiceFile failed"));
}
[Fact]
public async Task BackupAndClearAsync_BlobCopyThrows_StillAttemptsDbClearAndReturnsDbOutcome()
{
var (svc, log, invoices, blob) = MakeService(Invoice(), UnreachableConnectionString);
blob.Setup(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("blob boom"));
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
// The blob exception is swallowed and logged; the DB step still runs (and fails here for
// lack of a SQL Server) rather than the exception escaping BackupAndClearAsync.
Assert.False(result.Success);
Assert.Equal("db_error", result.Reason);
Assert.Null(result.BlobBackupUri);
Assert.Contains(log.Entries, e => e.Level == LogLevel.Error && e.Message.Contains("blob backup-copy threw"));
Assert.Contains(log.Entries, e => e.Level == LogLevel.Error && e.Message.Contains("fds__admin_backupAndClearInvoiceFile failed"));
}
}
+32
View File
@@ -0,0 +1,32 @@
using Fuchs.Services;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// <c>fds__invoice_items.reference_InvId</c> links a CanceledInvoice / IntInvoiceAllocation line to the
/// invoice it cancels / credits. It was never persisted from the editor, so fds__setInvoiceFinal
/// could not record allocations (and a NULL reference aborted its MERGE). The posted <c>ref</c> is
/// normalised by <see cref="InvoiceService.ItemReferenceInvId"/>.
/// </summary>
public class InvoiceItemReferenceTests
{
[Theory]
[InlineData("XH6EH", "XH6EH")]
[InlineData(" 6BOY0 ", "6BOY0")]
[InlineData("12345678901234567890", "12345678901234567890")] // exactly varchar(20)
public void ItemReferenceInvId_ValidId_ReturnsTrimmedId(string raw, string expected)
=> Assert.Equal(expected, InvoiceService.ItemReferenceInvId(raw));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("null")]
[InlineData("NULL")]
[InlineData("123456789012345678901")] // longer than the column
[InlineData("AB'; DROP")]
[InlineData("R2025-0244")] // an invoice *number*, not an invoice id
public void ItemReferenceInvId_InvalidOrMissing_ReturnsNull(string? raw)
=> Assert.Null(InvoiceService.ItemReferenceInvId(raw));
}
@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Notifications;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using MigraDoc.DocumentObjectModel;
using Moq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// The stored Leistungsdatum / -zeitraum must reach the invoice PDF's admin block (it was never
/// passed to the text blocks, so every invoice printed "Leistungszeitraum: -").
/// </summary>
public class InvoicePdfProvisionPeriodTests
{
private sealed class NoHttp : IHttpClientFactory
{
public HttpClient CreateClient(string name) => throw new InvalidOperationException("no HTTP expected");
}
[Theory]
[InlineData("2026-06-18", "18.06.2026")]
[InlineData("2026-06-01 - 2026-06-30", "01.06.2026 - 30.06.2026")]
[InlineData("18.06.2026", "18.06.2026")]
[InlineData("01.06.2026 - 30.06.2026", "01.06.2026 - 30.06.2026")]
[InlineData("R2026-0258", "R2026-0258")]
[InlineData("", "")]
[InlineData(null, "")]
[InlineData("nach Absprache", "nach Absprache")]
public void ProvisionPeriodForDisplay_MixedFormats_ShowsGermanDates(string? input, string expected)
{
Assert.Equal(expected, InvoiceService.ProvisionPeriodForDisplay(input));
}
[Theory]
[InlineData("18.06.2026", "18.06.2026", "Leistungsdatum")]
[InlineData("01.06.2026 - 30.06.2026", "01.06.2026 - 30.06.2026", "Leistungszeitraum")]
[InlineData("2026-06-18", "18.06.2026", "Leistungsdatum")]
[InlineData("2026-06-01 - 2026-06-30", "01.06.2026 - 30.06.2026", "Leistungszeitraum")]
[InlineData(" 18.06.2026 ", "18.06.2026", "Leistungsdatum")]
[InlineData("", "", "Leistungszeitraum")]
[InlineData(null, "", "Leistungszeitraum")]
public void ApplyProvisionPeriod_StoredValue_SetsGermanValueAndMatchingLabel(string? stored, string expectedValue, string expectedLabel)
{
var tb = new FuchsPdf.FdsTextBlocks();
InvoiceService.ApplyProvisionPeriod(tb, stored);
Assert.Equal(expectedValue, tb.ProvisionPeriod);
Assert.Equal(expectedLabel, tb.AdminProvLabel);
}
[Theory]
[InlineData("18.06.2026", "18.06.2026", "Leistungsdatum")]
[InlineData("2026-06-01 - 2026-06-30", "01.06.2026 - 30.06.2026", "Leistungszeitraum")]
[InlineData(null, "", "Leistungszeitraum")]
public async Task GenerateInvoicePdfAsync_StoredProvisionPeriod_ReachesAdminBlock(string? stored, string expectedValue, string expectedLabel)
{
FuchsPdf.FdsTextBlocks? captured = null;
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.Callback<FuchsPdf.FdsTextBlocks, bool>((tb, _) => captured = tb)
.ReturnsAsync(new Document());
var erechnung = new Mock<IERechnungService>();
erechnung.SetupGet(e => e.Enabled).Returns(false);
var config = new ConfigurationBuilder().Build();
var svc = new InvoiceService(new Fuchs_intranet(config), pdf.Object, new Mock<IBlobStorageService>().Object,
new Mock<IEventService>().Object, erechnung.Object, new NoHttp(), config, NullLogger<InvoiceService>.Instance);
var reg = new Dictionary<string, object?> { ["Id"] = "W7VAL", ["InvoiceId"] = "R2026-0258" };
if (stored != null) reg["ProvisionPeriod"] = stored;
var invoice = new FdsInvoiceData { InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(reg) };
await svc.GenerateInvoicePdfAsync(invoice, draft: true);
Assert.NotNull(captured);
Assert.Equal(expectedValue, captured!.ProvisionPeriod);
Assert.Equal(expectedLabel, captured.AdminProvLabel);
}
}
+39
View File
@@ -0,0 +1,39 @@
using Fuchs.Controllers;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// <c>inv/pget</c> is the first call of both storno paths. Intranet-created invoices carry an
/// alphanumeric Id (<c>fds__fn_invoice_id</c>, e.g. "XH6EH"); rejecting those with 400 left the
/// storno window empty. Only mfr invoice ids (positive integers) may trigger the mfr reset.
/// </summary>
public class InvoicePgetIdTests
{
[Theory]
[InlineData("12345", 12345L)]
[InlineData(" 987654321 ", 987654321L)]
[InlineData("9223372036854775807", long.MaxValue)]
public void TryGetMfrInvoiceId_NumericMfrId_ReturnsTrueWithValue(string raw, long expected)
{
Assert.True(IntranetController.TryGetMfrInvoiceId(raw, out long id));
Assert.Equal(expected, id);
}
[Theory]
[InlineData("XH6EH")] // Intranet invoice id
[InlineData("6BOY0")] // alphanumeric, starts with a digit
[InlineData("00000")] // numeric but not a valid (positive) mfr id
[InlineData("0")]
[InlineData("-5")]
[InlineData("1.5")]
[InlineData("1e3")]
[InlineData("9223372036854775808")] // overflow
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void TryGetMfrInvoiceId_NonMfrId_ReturnsFalse(string? raw)
{
Assert.False(IntranetController.TryGetMfrInvoiceId(raw, out _));
}
}
+62
View File
@@ -0,0 +1,62 @@
using System;
using Fuchs.Services;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// The invoice header save batch must stop as soon as fds__createInvoice / fds__setInvoice fails:
/// previously a parameter conversion error left @Id NULL and the batch went on to
/// fds__createInvoice_Details, whose "Cannot insert NULL into InvId" buried the real error.
/// </summary>
public class InvoiceRegisterSqlTests
{
[Theory]
[InlineData(false, "[dbo].[fds__createInvoice] ", "fds__createInvoice did not return")]
[InlineData(true, "[dbo].[fds__setInvoice] ", "fds__setInvoice did not return")]
public void BuildRegisterInvoiceSql_GuardSitsBetweenHeaderProcAndDetails(bool change, string headerProc, string guardText)
{
string sql = InvoiceService.BuildRegisterInvoiceSql(change, hasProvisionLocation: true);
int header = sql.IndexOf(headerProc, StringComparison.Ordinal);
int guard = sql.IndexOf("IF @Id IS NULL THROW", StringComparison.Ordinal);
int details = sql.IndexOf("[dbo].[fds__createInvoice_Details]", StringComparison.Ordinal);
int update = sql.IndexOf("UPDATE [dbo].[fds__invoices]", StringComparison.Ordinal);
Assert.True(header >= 0, "header proc missing");
Assert.True(header < guard && guard < details && details < update, sql);
Assert.Contains(guardText, sql);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void BuildRegisterInvoiceSql_ExactlyOneHeaderProcPerMode(bool change)
{
string sql = InvoiceService.BuildRegisterInvoiceSql(change, hasProvisionLocation: false);
Assert.Equal(change, sql.Contains("[dbo].[fds__setInvoice] @InvId,"));
Assert.Equal(!change, sql.Contains("[dbo].[fds__createInvoice] @InvoiceType,"));
Assert.Equal(change, sql.Contains("@InvId"));
}
[Theory]
[InlineData(false, false)]
[InlineData(true, true)]
public void BuildRegisterInvoiceSql_ProvisionLocationUpdateOnlyWhenPresent(bool hasProvisionLocation, bool expected)
{
string sql = InvoiceService.BuildRegisterInvoiceSql(change: true, hasProvisionLocation);
Assert.Equal(expected, sql.Contains("@ProvisionLocation"));
}
[Fact]
public void BuildRegisterInvoiceSql_EveryStatementIsSemicolonTerminated()
{
// THROW requires the preceding statement to be terminated with ';'.
string sql = InvoiceService.BuildRegisterInvoiceSql(change: true, hasProvisionLocation: true);
foreach (var line in sql.Split('\n'))
Assert.EndsWith(";", line.TrimEnd());
}
}
+149
View File
@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Notifications;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
using Moq;
using OCORE.security;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Exercises the failure branches of <see cref="InvoiceService.StoreInvoiceDocumentFileAsync"/>.
/// The method signals every failure with an empty array, so the three distinct causes — render
/// threw, render produced no bytes, DB store failed — are only distinguishable by what gets
/// logged. Each must be logged individually, and none may reach the blob upload or the
/// InvoiceFileCreated event. No SQL Server: the store-failure case uses an unconfigured
/// connection so the write fails fast inside the OCORE helper.
/// </summary>
public class InvoiceServiceStoreFileTests
{
private sealed class RecordingLogger : ILogger<InvoiceService>
{
public sealed record Entry(LogLevel Level, Exception? Exception, string Message);
public readonly List<Entry> Entries = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add(new Entry(logLevel, exception, formatter(state, exception)));
}
private sealed class NoHttp : IHttpClientFactory
{
public HttpClient CreateClient(string name) => throw new InvalidOperationException("no HTTP expected");
}
// A refused local port: the fds__setInvoiceFile write fails inside the OCORE helper's own
// try/catch (returning false) instead of hanging on a connect timeout.
private const string UnreachableConnectionString =
"Server=127.0.0.1,1;Database=fuchs;User Id=u;Password=p;Connect Timeout=1;TrustServerCertificate=True";
private static (InvoiceService svc, RecordingLogger log, Mock<IBlobStorageService> blob, Mock<IEventService> events)
MakeService(Mock<IPdfService> pdf, string? connectionString = null)
{
var log = new RecordingLogger();
var blob = new Mock<IBlobStorageService>();
var events = new Mock<IEventService>();
var erechnung = new Mock<IERechnungService>();
erechnung.SetupGet(e => e.Enabled).Returns(false);
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:fuchs_fds_ConnectionString"] = connectionString
}).Build();
var svc = new InvoiceService(
new Fuchs_intranet(config),
pdf.Object, blob.Object, events.Object, erechnung.Object,
new NoHttp(), config, log);
return (svc, log, blob, events);
}
private static FdsInvoiceData Invoice(bool isFinal) => new()
{
InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>
{
["Id"] = "W7VAL",
["InvoiceId"] = "R2026-0258",
["IsFinal"] = isFinal
}),
IsDraft = !isFinal
};
private static Mock<IPdfService> PdfReturning(byte[] bytes)
{
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.ReturnsAsync(new Document());
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<Document>())).Returns(bytes);
return pdf;
}
private static void AssertNothingPersisted(Mock<IBlobStorageService> blob, Mock<IEventService> events)
{
blob.Verify(b => b.UploadInvoicePdfAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<byte[]>(),
It.IsAny<IReadOnlyDictionary<string, object?>>(), It.IsAny<CancellationToken>()), Times.Never);
events.Verify(e => e.InvoiceFileCreatedAsync(It.IsAny<FdsInvoiceData>(), It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task StoreInvoiceDocumentFileAsync_RenderProducesZeroBytes_ReturnsEmptyAndLogsError(bool draft)
{
var (svc, log, blob, events) = MakeService(PdfReturning(Array.Empty<byte>()));
var result = await svc.StoreInvoiceDocumentFileAsync(Invoice(isFinal: !draft), draft, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
Assert.Null(entry.Exception);
Assert.Contains("produced 0 bytes", entry.Message);
Assert.Contains("W7VAL", entry.Message);
Assert.Contains($"draft={draft}", entry.Message);
AssertNothingPersisted(blob, events);
}
[Fact]
public async Task StoreInvoiceDocumentFileAsync_RenderThrows_ReturnsEmptyAndLogsExceptionNotZeroBytes()
{
var pdf = new Mock<IPdfService>();
var boom = new InvalidOperationException("font resolver exploded");
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>())).ThrowsAsync(boom);
var (svc, log, blob, events) = MakeService(pdf);
var result = await svc.StoreInvoiceDocumentFileAsync(Invoice(isFinal: true), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
Assert.Same(boom, entry.Exception);
Assert.Contains("render failed", entry.Message);
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("produced 0 bytes"));
AssertNothingPersisted(blob, events);
}
[Fact]
public async Task StoreInvoiceDocumentFileAsync_RenderOkButDbStoreFails_ReturnsEmptyWithoutRenderErrorAndSkipsBlob()
{
// Render succeeds; the fds__setInvoiceFile write fails (no connection configured). The
// SQL error is reported through FIS_SQLOptions.OnError, not through the render branches —
// so neither render message may appear, and the PDF must not be archived to blob storage
// as if the primary DB write had succeeded.
var (svc, log, blob, events) = MakeService(PdfReturning(new byte[] { 0x25, 0x50, 0x44, 0x46 }), UnreachableConnectionString);
var result = await svc.StoreInvoiceDocumentFileAsync(Invoice(isFinal: true), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("produced 0 bytes"));
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("render failed"));
AssertNothingPersisted(blob, events);
}
}
+5 -14
View File
@@ -11,7 +11,6 @@ using eRechnungLib.Profiles;
using Fuchs.intranet; using Fuchs.intranet;
using Fuchs.Services; using Fuchs.Services;
using MigraDoc.DocumentObjectModel; using MigraDoc.DocumentObjectModel;
using MigraDoc.Rendering;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using PdfSharp.Pdf.IO; using PdfSharp.Pdf.IO;
using Xunit; using Xunit;
@@ -31,14 +30,6 @@ public class PdfPipelineTests
// ── Stage 1 helper: a "dummy" visual invoice PDF built purely with PdfSharp/MigraDoc ── // ── Stage 1 helper: a "dummy" visual invoice PDF built purely with PdfSharp/MigraDoc ──
private static byte[] BuildDummyPdfWithPdfSharp() 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(); var doc = new Document();
doc.Info.Title = "Dummy Rechnung"; doc.Info.Title = "Dummy Rechnung";
var normal = doc.Styles["Normal"]!; var normal = doc.Styles["Normal"]!;
@@ -50,11 +41,11 @@ public class PdfPipelineTests
sec.AddParagraph("Position 1: Beratungsleistung — 200,00 EUR netto"); sec.AddParagraph("Position 1: Beratungsleistung — 200,00 EUR netto");
sec.AddParagraph("Position 2: Entwicklung — 500,00 EUR netto"); sec.AddParagraph("Position 2: Entwicklung — 500,00 EUR netto");
var renderer = new PdfDocumentRenderer { Document = doc }; // Goes through FuchsPdf.DocToPdfBytesRaw so the font resolver is installed exactly the way
renderer.RenderDocument(); // (and exactly once, thread-safely) the production render path installs it — a second,
using var ms = new MemoryStream(); // separate check-then-set here previously raced PdfSharp's "must not change font resolver
renderer.PdfDocument.Save(ms, closeStream: false); // after it was once used" guard under parallel test execution.
return ms.ToArray(); return FuchsPdf.DocToPdfBytesRaw(doc);
} }
// ── Stage 3 helper: a minimal but EN 16931-complete domestic invoice model ── // ── Stage 3 helper: a minimal but EN 16931-complete domestic invoice model ──
+384 -249
View File
@@ -3,65 +3,71 @@ using System.Collections.Generic;
using System.Diagnostics.Metrics; using System.Diagnostics.Metrics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Fuchs.Services; using Fuchs.Services;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Moq;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using OCORE.web.pwmailer;
using Xunit; using Xunit;
namespace Fuchs.Tests; namespace Fuchs.Tests;
/// <summary> /// <summary>
/// Tests for the outbound communication service. Cover both intentionally /// Tests for the outbound communication service as an adapter over the OCORE_web
/// succeeding (API 200) and intentionally failing (invalid email, disabled, /// <see cref="IPWMailerClientService"/> (ADR 0029). Cover both intentionally succeeding and
/// API 500) paths, plus the attachment payload contract and metric emission. /// intentionally failing paths (invalid email, disabled, API error, duplicate, refused by
/// pre-validation, attachment count not confirmed), the <see cref="PWMailerMessage"/> handed to
/// the client, the audit log written on every path, and metric emission. The HTTP contract itself
/// (JSON vs multipart, unnamed envelope field, attachmentCount) is the client's responsibility and
/// is tested in OCORE_webTests/comm/PWMailerClientServiceTests.cs.
/// ///
/// Fuchs_intranet is passed as null: it is only touched by the audit-log write, /// Fuchs_intranet is passed as null: it is only touched by the audit-log write,
/// which is wrapped in try/catch and therefore harmless in a unit test. /// which is wrapped in try/catch and therefore harmless in a unit test.
/// </summary> /// </summary>
public class ProcessWebComServiceTests public class ProcessWebComServiceTests
{ {
private const string FuchsMeterName = "Fuchs.Intranet";
// ── Test doubles ──────────────────────────────────────────────────────── // ── Test doubles ────────────────────────────────────────────────────────
private sealed class StubHandler : HttpMessageHandler private static PWMailerResult<PWMailerPushReceipt> Accepted(int received = 0, int processed = 0, string comStackId = "cs-1") =>
{ new() { StatusCode = 200, Value = new PWMailerPushReceipt(comStackId, received, processed), Attempts = 1, Operation = "push_com" };
private readonly HttpStatusCode _status;
private readonly string _body;
public string? LastRequestBody { get; private set; }
public int CallCount { get; private set; }
public StubHandler(HttpStatusCode status, string body = "OK") private static PWMailerResult<PWMailerPushReceipt> Failed(int status, string error) =>
new() { StatusCode = status, Error = error, Attempts = 1, Operation = "push_com" };
/// <summary>The client refused the call before sending (validation, options, token): status 0, no attempt.</summary>
private static PWMailerResult<PWMailerPushReceipt> Refused(string error) =>
new() { StatusCode = 0, Error = error, Attempts = 0, Operation = "push_com", Exception = new ArgumentException(error) };
private sealed class MailerStub
{
public readonly Mock<IPWMailerClientService> Mock = new(MockBehavior.Loose);
public readonly List<PWMailerMessage> Pushed = new();
public int CallCount => Pushed.Count;
public MailerStub(PWMailerResult<PWMailerPushReceipt>? push = null)
{ {
_status = status; var result = push ?? Accepted();
_body = body; Mock.Setup(m => m.PushAsync(It.IsAny<PWMailerMessage>(), It.IsAny<CancellationToken>()))
.Callback<PWMailerMessage, CancellationToken>((m, _) => Pushed.Add(m))
.ReturnsAsync(result);
} }
protected override async Task<HttpResponseMessage> SendAsync( /// <summary>Answers like a Mailer that processed every file it received.</summary>
HttpRequestMessage request, CancellationToken cancellationToken) public static MailerStub Echoing()
{ {
CallCount++; var stub = new MailerStub();
LastRequestBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); stub.Mock.Setup(m => m.PushAsync(It.IsAny<PWMailerMessage>(), It.IsAny<CancellationToken>()))
return new HttpResponseMessage(_status) { Content = new StringContent(_body) }; .Callback<PWMailerMessage, CancellationToken>((m, _) => stub.Pushed.Add(m))
.ReturnsAsync((PWMailerMessage m, CancellationToken _) => Accepted(m.Attachments.Count, m.Attachments.Count));
return stub;
} }
} }
private sealed class ThrowingHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> throw new HttpRequestException("simulated transport failure");
}
private sealed class StubHttpClientFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public StubHttpClientFactory(HttpMessageHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
}
/// <summary> /// <summary>
/// Captures what would be written to <c>fds__logEmail</c> without hitting a database, by /// Captures what would be written to <c>fds__logEmail</c> without hitting a database, by
/// overriding the audit-log write. Lets tests assert that every send path is logged and that /// overriding the audit-log write. Lets tests assert that every send path is logged and that
@@ -70,145 +76,129 @@ public class ProcessWebComServiceTests
private sealed class CapturingComService : ProcessWebComService private sealed class CapturingComService : ProcessWebComService
{ {
public readonly List<(bool success, List<string> log)> Entries = new(); public readonly List<(bool success, List<string> log)> Entries = new();
public readonly List<string> Configs = new();
public readonly List<string> Guids = new();
public CapturingComService( public CapturingComService(IOptions<ProcessWebComSettings> settings, IOptions<FuchsEmailSettings> emailSettings,
IOptions<ProcessWebComSettings> settings, IPWMailerClientService mailer)
IOptions<FuchsEmailSettings> emailSettings, : base(NullLogger<ProcessWebComService>.Instance, intranet: null!, settings, emailSettings, mailer) { }
IHttpClientFactory httpClientFactory)
: base(NullLogger<ProcessWebComService>.Instance, intranet: null!, settings, emailSettings, httpClientFactory)
{
}
protected override Task WriteAuditLogAsync(string reference, string guid, string config, protected override Task WriteAuditLogAsync(string reference, string guid, string config,
DateTime sent, bool success, IEnumerable<string> errors) DateTime sent, bool success, IEnumerable<string> errors)
{ {
Entries.Add((success, errors.ToList())); Entries.Add((success, errors.ToList()));
Configs.Add(config);
Guids.Add(guid);
return Task.CompletedTask; return Task.CompletedTask;
} }
} }
private static CapturingComService CreateCapturing(HttpMessageHandler handler, bool enabled = true, string? overrideRecipient = null) private static IOptions<ProcessWebComSettings> Settings(bool enabled = true, string accountId = "acct", string token = "tok", string serverId = "") =>
{ Options.Create(new ProcessWebComSettings
var settings = Options.Create(new ProcessWebComSettings
{ {
Enabled = enabled, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok" Enabled = enabled, BaseUrl = "https://mailer.test", AccountId = accountId, Token = token, ServerId = serverId
}); });
var emailSettings = Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient });
return new CapturingComService(settings, emailSettings, new StubHttpClientFactory(handler));
}
private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true, string? overrideRecipient = null) private static CapturingComService CreateCapturing(MailerStub mailer, bool enabled = true, string? overrideRecipient = null) =>
{ new(Settings(enabled), Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient }), mailer.Mock.Object);
var settings = Options.Create(new ProcessWebComSettings
{ private static ProcessWebComService CreateService(MailerStub mailer, bool enabled = true, string? overrideRecipient = null) =>
Enabled = enabled, new(NullLogger<ProcessWebComService>.Instance, intranet: null!, Settings(enabled),
BaseUrl = "https://mailer.test", Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient }), mailer.Mock.Object);
AccountId = "acct",
Token = "tok"
});
var emailSettings = Options.Create(new FuchsEmailSettings
{
OverrideRecipient = overrideRecipient
});
return new ProcessWebComService(
NullLogger<ProcessWebComService>.Instance,
intranet: null!,
settings,
emailSettings,
new StubHttpClientFactory(handler));
}
// ── Success path ───────────────────────────────────────────────────────── // ── Success path ─────────────────────────────────────────────────────────
[Fact] [Fact]
public async Task SendEmailAsync_ValidAndApiOk_ReturnsTrueAndPostsOnce() public async Task SendEmailAsync_ValidAndApiOk_ReturnsTrueAndPushesOnce()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler); var svc = CreateService(mailer);
bool result = await svc.SendEmailAsync("inv_1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde"); bool result = await svc.SendEmailAsync("inv_1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
Assert.True(result); Assert.True(result);
Assert.Equal(1, handler.CallCount); Assert.Equal(1, mailer.CallCount);
} }
// ── Failure paths ────────────────────────────────────────────────────────── // ── Failure paths ──────────────────────────────────────────────────────────
[Fact] [Fact]
public async Task SendEmailAsync_ApiError_ReturnsFalse() public async Task SendEmailAsync_ApiError_ReturnsFalse()
{ {
var handler = new StubHandler(HttpStatusCode.InternalServerError, "boom"); var mailer = new MailerStub(Failed(500, "boom"));
var svc = CreateService(handler); var svc = CreateService(mailer);
bool result = await svc.SendEmailAsync("inv_2", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde"); bool result = await svc.SendEmailAsync("inv_2", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
Assert.False(result); Assert.False(result);
Assert.Equal(1, handler.CallCount); Assert.Equal(1, mailer.CallCount);
} }
[Theory] [Theory]
[InlineData("not-an-email")] [InlineData("not-an-email")]
[InlineData("")] [InlineData("")]
[InlineData("missing@")] [InlineData("missing@")]
public async Task SendEmailAsync_InvalidEmail_ReturnsFalseWithoutCallingApi(string badEmail) public async Task SendEmailAsync_InvalidEmail_ReturnsFalseWithoutCallingMailer(string badEmail)
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler); var svc = CreateService(mailer);
bool result = await svc.SendEmailAsync("inv_3", "Subject", "<p>hi</p>", badEmail, "Kunde"); bool result = await svc.SendEmailAsync("inv_3", "Subject", "<p>hi</p>", badEmail, "Kunde");
Assert.False(result); Assert.False(result);
Assert.Equal(0, handler.CallCount); // never reached the API Assert.Equal(0, mailer.CallCount);
} }
[Fact] [Fact]
public async Task SendEmailAsync_Disabled_ReturnsFalseWithoutCallingApi() public async Task SendEmailAsync_Disabled_ReturnsFalseWithoutCallingMailer()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler, enabled: false); var svc = CreateService(mailer, enabled: false);
bool result = await svc.SendEmailAsync("inv_4", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde"); bool result = await svc.SendEmailAsync("inv_4", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
Assert.False(result); Assert.False(result);
Assert.Equal(0, handler.CallCount); Assert.Equal(0, mailer.CallCount);
} }
// ── Audit logging: every attempt is logged; failures carry the service response ─ // ── Audit logging: every attempt is logged; failures carry the client's error ─
[Fact] [Fact]
public async Task SendEmailAsync_Success_LogsSuccessfulAttempt() public async Task SendEmailAsync_Success_LogsSuccessfulAttemptWithComStackId()
{ {
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK)); var svc = CreateCapturing(new MailerStub(Accepted(comStackId: "cs-42")));
await svc.SendEmailAsync("inv_log_ok", "S", "<p>x</p>", "kunde@example.de", "Kunde"); await svc.SendEmailAsync("inv_log_ok", "S", "<p>x</p>", "kunde@example.de", "Kunde");
var entry = Assert.Single(svc.Entries); var entry = Assert.Single(svc.Entries);
Assert.True(entry.success); Assert.True(entry.success);
Assert.Equal("cs-42", Assert.Single(svc.Guids)); // what getComStatus/getCom accept
} }
[Fact] [Fact]
public async Task SendEmailAsync_ApiError_LogsFailureIncludingServiceResponse() public async Task SendEmailAsync_ApiError_LogsFailureIncludingStatusAndError()
{ {
var svc = CreateCapturing(new StubHandler(HttpStatusCode.InternalServerError, "mailer rejected: quota exceeded")); var svc = CreateCapturing(new MailerStub(Failed(500, "mailer rejected: quota exceeded")));
bool result = await svc.SendEmailAsync("inv_log_api", "S", "<p>x</p>", "kunde@example.de", "Kunde"); bool result = await svc.SendEmailAsync("inv_log_api", "S", "<p>x</p>", "kunde@example.de", "Kunde");
Assert.False(result); Assert.False(result);
var entry = Assert.Single(svc.Entries); var entry = Assert.Single(svc.Entries);
Assert.False(entry.success); Assert.False(entry.success);
Assert.Contains(entry.log, l => l.Contains("mailer rejected: quota exceeded")); // raw service response Assert.Contains(entry.log, l => l.Contains("mailer rejected: quota exceeded") && l.Contains("500"));
Assert.Contains(entry.log, l => l.Contains("500")); // HTTP status
} }
[Fact] [Fact]
public async Task SendEmailAsync_TransportException_LogsFailureIncludingExceptionDetail() public async Task SendEmailAsync_ClientThrows_LogsFailureIncludingExceptionDetail()
{ {
var svc = CreateCapturing(new ThrowingHandler()); var mailer = new MailerStub();
mailer.Mock.Setup(m => m.PushAsync(It.IsAny<PWMailerMessage>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("simulated client failure"));
var svc = CreateCapturing(mailer);
bool result = await svc.SendEmailAsync("inv_log_ex", "S", "<p>x</p>", "kunde@example.de", "Kunde"); bool result = await svc.SendEmailAsync("inv_log_ex", "S", "<p>x</p>", "kunde@example.de", "Kunde");
Assert.False(result); Assert.False(result);
var entry = Assert.Single(svc.Entries); var entry = Assert.Single(svc.Entries);
Assert.False(entry.success); Assert.False(entry.success);
Assert.Contains(entry.log, l => l.Contains("Exception while sending")); Assert.Contains(entry.log, l => l.Contains("Exception while sending") && l.Contains("simulated client failure"));
Assert.Contains(entry.log, l => l.Contains("simulated transport failure"));
} }
[Theory] [Theory]
@@ -216,7 +206,7 @@ public class ProcessWebComServiceTests
[InlineData("")] [InlineData("")]
public async Task SendEmailAsync_InvalidEmail_StillLogsFailedAttempt(string badEmail) public async Task SendEmailAsync_InvalidEmail_StillLogsFailedAttempt(string badEmail)
{ {
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK)); var svc = CreateCapturing(new MailerStub());
bool result = await svc.SendEmailAsync("inv_log_bad", "S", "<p>x</p>", badEmail, "Kunde"); bool result = await svc.SendEmailAsync("inv_log_bad", "S", "<p>x</p>", badEmail, "Kunde");
@@ -229,7 +219,7 @@ public class ProcessWebComServiceTests
[Fact] [Fact]
public async Task SendEmailAsync_Disabled_LogsFailedAttempt() public async Task SendEmailAsync_Disabled_LogsFailedAttempt()
{ {
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK), enabled: false); var svc = CreateCapturing(new MailerStub(), enabled: false);
await svc.SendEmailAsync("inv_log_dis", "S", "<p>x</p>", "kunde@example.de", "Kunde"); await svc.SendEmailAsync("inv_log_dis", "S", "<p>x</p>", "kunde@example.de", "Kunde");
@@ -267,31 +257,113 @@ public class ProcessWebComServiceTests
Assert.Equal(when, pl.Single(p => p.ParameterName == "@DateSent").Value); Assert.Equal(when, pl.Single(p => p.ParameterName == "@DateSent").Value);
} }
// ── Audit-log config: fds__emaillog.config must be the JSON fds_wgd_lastEmails parses ─
// Regression: the service logged config = "" on every path; OPENJSON('') throws, which took
// the "last emails" dashboard widget down and lost who/what/attachments from the audit trail.
[Fact]
public void BuildAuditConfig_FullInput_ProducesLegacyShapeWithoutBody()
{
var files = new Dictionary<string, byte[]> { ["Rechnung R2026-0119.pdf"] = [1, 2], ["x.xml"] = [3] };
string json = ProcessWebComService.BuildAuditConfig(
"SanitärFuchs - Rechnung", "buchhaltung@example.de", "Buchhaltung Erne", "chef@example.de", files.Keys);
var o = JObject.Parse(json);
Assert.Equal("SanitärFuchs - Rechnung", (string?)o["subject"]);
Assert.Equal("", (string?)o["from"]);
Assert.Equal(new[] { "\"Buchhaltung Erne\" <buchhaltung@example.de>" }, o["to"]!.Select(t => (string?)t));
Assert.Equal(new[] { "chef@example.de" }, o["cc"]!.Select(t => (string?)t));
Assert.Empty(o["bcc"]!);
Assert.Equal(new[] { "Rechnung R2026-0119.pdf", "x.xml" }, o["attachments"]!.Select(t => (string?)t));
Assert.Null(o["body"]); // never the HTML body
Assert.DoesNotContain("AQI=", json); // never attachment bytes (base64 of 1,2)
}
[Theory]
[InlineData(null, "a@b.de", "a@b.de")] // no name → bare address
[InlineData("", "a@b.de", "a@b.de")]
[InlineData(" ", "a@b.de", "a@b.de")]
[InlineData("Ann", "a@b.de", "\"Ann\" <a@b.de>")] // name → quoted display form the widget unwraps
[InlineData(" Ann ", "a@b.de", "\"Ann\" <a@b.de>")]
public void BuildAuditConfig_RecipientName_FormatsToArrayEntry(string? name, string email, string expected)
{
var o = JObject.Parse(ProcessWebComService.BuildAuditConfig("S", email, name, null, null));
Assert.Equal(new[] { expected }, o["to"]!.Select(t => (string?)t));
Assert.Empty(o["cc"]!);
Assert.Empty(o["attachments"]!);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void BuildAuditConfig_NullOrEmptySubject_StillValidJson(string? subject)
{
var o = JObject.Parse(ProcessWebComService.BuildAuditConfig(subject!, "a@b.de", null, null, null));
Assert.Equal("", (string?)o["subject"]);
}
public static IEnumerable<object[]> AllAuditPaths() =>
[
[ "success", 200, true, "kunde@example.de" ],
[ "api-error", 500, true, "kunde@example.de" ],
[ "disabled", 200, false, "kunde@example.de" ],
[ "invalid-email", 200, true, "not-an-email" ],
];
[Theory]
[MemberData(nameof(AllAuditPaths))]
public async Task SendEmailAsync_EveryAuditPath_LogsParsableConfigWithRecipientSubjectAndAttachments(
string path, int status, bool enabled, string email)
{
var mailer = status == 200 ? MailerStub.Echoing() : new MailerStub(Failed(status, "err"));
var svc = CreateCapturing(mailer, enabled: enabled);
var att = new Dictionary<string, byte[]> { ["Mahnung.pdf"] = [9, 9] };
await svc.SendEmailAsync("inv_cfg_" + path, "Subj " + path, "<p>x</p>", email, "Kunde", att);
string config = Assert.Single(svc.Configs);
var o = JObject.Parse(config); // was "" → OPENJSON error in fds_wgd_lastEmails
Assert.Equal("Subj " + path, (string?)o["subject"]);
Assert.Equal(new[] { $"\"Kunde\" <{email}>" }, o["to"]!.Select(t => (string?)t));
Assert.Equal(new[] { "Mahnung.pdf" }, o["attachments"]!.Select(t => (string?)t));
}
[Fact]
public async Task SendEmailAsync_OverrideRecipientSet_ConfigRecordsActualRecipientAndPrefixedSubject()
{
var svc = CreateCapturing(new MailerStub(), overrideRecipient: "dev-inbox@example.test");
await svc.SendEmailAsync("inv_cfg_ovr", "Subject", "<p>x</p>", "kunde@example.de", "Kunde", null, cc: "cc@example.de");
var o = JObject.Parse(Assert.Single(svc.Configs));
Assert.Equal(new[] { "\"Kunde\" <dev-inbox@example.test>" }, o["to"]!.Select(t => (string?)t)); // where it really went
Assert.Empty(o["cc"]!); // cc is dropped by the override
Assert.StartsWith("[DEV → kunde@example.de] ", (string?)o["subject"]); // original recipient stays traceable
}
// ── Dev/test recipient override safety net ───────────────────────────────── // ── Dev/test recipient override safety net ─────────────────────────────────
[Fact] [Fact]
public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress() public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test"); var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test");
bool result = await svc.SendEmailAsync("inv_ov1", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde"); bool result = await svc.SendEmailAsync("inv_ov1", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde");
Assert.True(result); Assert.True(result);
Assert.Equal(1, handler.CallCount); Assert.Equal("dev-inbox@example.test", Assert.Single(mailer.Pushed).To);
var json = JObject.Parse(handler.LastRequestBody!);
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
} }
[Fact] [Fact]
public async Task SendEmailAsync_OverrideRecipientSet_SubjectRetainsOriginalRecipientForTraceability() public async Task SendEmailAsync_OverrideRecipientSet_SubjectRetainsOriginalRecipientForTraceability()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test"); var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test");
await svc.SendEmailAsync("inv_ov2", "Rechnung 123", "<p>hi</p>", "realcustomer@example.de", "Kunde"); await svc.SendEmailAsync("inv_ov2", "Rechnung 123", "<p>hi</p>", "realcustomer@example.de", "Kunde");
var json = JObject.Parse(handler.LastRequestBody!); string subject = Assert.Single(mailer.Pushed).Subject;
string subject = json["communication"]!["subject"]!.ToString();
Assert.Contains("realcustomer@example.de", subject); Assert.Contains("realcustomer@example.de", subject);
Assert.Contains("Rechnung 123", subject); Assert.Contains("Rechnung 123", subject);
} }
@@ -299,84 +371,69 @@ public class ProcessWebComServiceTests
[Fact] [Fact]
public async Task SendEmailAsync_OverrideRecipientSet_InvalidOriginalAddressIsStillRedirected() public async Task SendEmailAsync_OverrideRecipientSet_InvalidOriginalAddressIsStillRedirected()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test"); var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test");
bool result = await svc.SendEmailAsync("inv_ov3", "Subject", "<p>hi</p>", "not-an-email", "Kunde"); bool result = await svc.SendEmailAsync("inv_ov3", "Subject", "<p>hi</p>", "not-an-email", "Kunde");
Assert.True(result); Assert.True(result);
Assert.Equal(1, handler.CallCount); Assert.Equal("dev-inbox@example.test", Assert.Single(mailer.Pushed).To);
var json = JObject.Parse(handler.LastRequestBody!);
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
} }
[Fact] [Fact]
public async Task SendEmailAsync_NoOverrideConfigured_SendsToOriginalRecipient() public async Task SendEmailAsync_NoOverrideConfigured_SendsToOriginalRecipient()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler); var svc = CreateService(mailer);
await svc.SendEmailAsync("inv_ov4", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde"); await svc.SendEmailAsync("inv_ov4", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde");
var json = JObject.Parse(handler.LastRequestBody!); Assert.Equal("realcustomer@example.de", Assert.Single(mailer.Pushed).To);
Assert.Equal("realcustomer@example.de", json["communication"]!["to"]!.ToString());
} }
// ── Cc recipient ("Zusatzempfänger") ───────────────────────────────────────── // ── Cc recipient ("Zusatzempfänger") ─────────────────────────────────────────
[Fact] [Fact]
public async Task SendEmailAsync_CcProvided_IsIncludedInPayload() public async Task SendEmailAsync_CcProvided_IsIncludedInMessage()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler); var svc = CreateService(mailer);
await svc.SendEmailAsync("inv_cc1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", await svc.SendEmailAsync("inv_cc1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", cc: "zusatz@example.de");
cc: "zusatz@example.de");
var json = JObject.Parse(handler.LastRequestBody!); Assert.Equal("zusatz@example.de", Assert.Single(mailer.Pushed).Cc);
Assert.Equal("zusatz@example.de", json["communication"]!["cc"]!.ToString());
} }
[Fact] [Fact]
public async Task SendEmailAsync_CcOmitted_IsNullInPayload() public async Task SendEmailAsync_CcOmitted_IsNullInMessage()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler); var svc = CreateService(mailer);
await svc.SendEmailAsync("inv_cc2", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde"); await svc.SendEmailAsync("inv_cc2", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
var json = JObject.Parse(handler.LastRequestBody!); Assert.Null(Assert.Single(mailer.Pushed).Cc);
Assert.Equal(JTokenType.Null, json["communication"]!["cc"]!.Type);
} }
[Fact] [Fact]
public async Task SendEmailAsync_OverrideRecipientSet_DropsCcEntirely() public async Task SendEmailAsync_OverrideRecipientSet_DropsCcEntirely()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test"); var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test");
await svc.SendEmailAsync("inv_cc3", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde", await svc.SendEmailAsync("inv_cc3", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde", cc: "zusatz@example.de");
cc: "zusatz@example.de");
var json = JObject.Parse(handler.LastRequestBody!); var m = Assert.Single(mailer.Pushed);
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString()); Assert.Equal("dev-inbox@example.test", m.To);
Assert.Equal(JTokenType.Null, json["communication"]!["cc"]!.Type); Assert.Null(m.Cc);
} }
// ── Override enforcement across every real appsettings*.json environment ─── // ── Override enforcement across every real appsettings*.json environment ───
// Loads the ACTUAL Fuchs/appsettings*.json files the same way ASP.NET Core // Loads the ACTUAL Fuchs/appsettings*.json files the same way ASP.NET Core layers them (base
// layers them (base file + optional environment-specific overlay). These files // file + optional environment-specific overlay). These files are copied into this test
// are copied into this test assembly's output directory via the Fuchs project // assembly's output directory via the Fuchs project reference. This proves the safety net holds
// reference (Microsoft.NET.Sdk.Web auto-includes appsettings*.json as Content // for whichever environment/appsettings file ends up "active" at runtime — not just a hand-typed
// with CopyToOutputDirectory). This proves the safety net holds for whichever // literal — so a future edit that silently breaks the override key or its value in any
// environment/appsettings file ends up "active" at runtime - not just a // appsettings*.json file would fail this test.
// hand-typed literal - so a future edit that silently breaks the override key
// or its value in any appsettings*.json file would fail this test.
//
// Note: this API has a single "communication.to" field - there is no distinct
// to/cc/bcc concept anywhere in the codebase (see ProcessWebComService.
// SendEmailAsync / communication.to). "to/cc/bcc cleared" is therefore fully
// satisfied by asserting that field no longer carries the original recipient
// once an override is configured.
public static IEnumerable<object[]> AppSettingsEnvironments() public static IEnumerable<object[]> AppSettingsEnvironments()
{ {
yield return new object[] { "" }; // base appsettings.json only ("Production"-like default) yield return new object[] { "" }; // base appsettings.json only ("Production"-like default)
@@ -403,8 +460,8 @@ public class ProcessWebComServiceTests
public async Task SendEmailAsync_OverrideRecipientFromRealAppsettings_ClearsRecipientWheneverConfigured(string environmentName) public async Task SendEmailAsync_OverrideRecipientFromRealAppsettings_ClearsRecipientWheneverConfigured(string environmentName)
{ {
string overrideRecipient = ResolveOverrideRecipientForEnvironment(environmentName); string overrideRecipient = ResolveOverrideRecipientForEnvironment(environmentName);
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler, overrideRecipient: overrideRecipient); var svc = CreateService(mailer, overrideRecipient: overrideRecipient);
const string originalRecipient = "realcustomer@tenant-owner.example"; const string originalRecipient = "realcustomer@tenant-owner.example";
bool result = await svc.SendEmailAsync( bool result = await svc.SendEmailAsync(
@@ -412,134 +469,226 @@ public class ProcessWebComServiceTests
"Subject", "<p>hi</p>", originalRecipient, "Kunde"); "Subject", "<p>hi</p>", originalRecipient, "Kunde");
Assert.True(result); Assert.True(result);
Assert.Equal(1, handler.CallCount); string sentRecipient = Assert.Single(mailer.Pushed).To;
var json = JObject.Parse(handler.LastRequestBody!);
string sentRecipient = json["communication"]!["to"]!.ToString();
if (!string.IsNullOrWhiteSpace(overrideRecipient)) if (!string.IsNullOrWhiteSpace(overrideRecipient))
{ {
// Override configured for this environment: the real recipient (to/cc/bcc)
// must be fully cleared and replaced by the override - never leaked.
Assert.Equal(overrideRecipient, sentRecipient); Assert.Equal(overrideRecipient, sentRecipient);
Assert.NotEqual(originalRecipient, sentRecipient); Assert.NotEqual(originalRecipient, sentRecipient);
} }
else else
{ {
// No override configured for this environment: real recipient is used normally.
Assert.Equal(originalRecipient, sentRecipient); Assert.Equal(originalRecipient, sentRecipient);
} }
} }
// ── Attachment payload contract ──────────────────────────────────────────── // ── The message handed to the PWMailer client ─────────────────────────────
[Fact] [Fact]
public async Task SendEmailAsync_WithAttachment_EmbedsBase64InPayload() public async Task SendEmailAsync_MapsEnvelopeFieldsAndAttachmentsOntoPWMailerMessage()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = MailerStub.Echoing();
var svc = CreateService(handler); var svc = CreateService(mailer);
byte[] pdf = { 1, 2, 3, 4 }; byte[] pdf = { 0x25, 0x50, 0x44, 0x46 };
var attachments = new Dictionary<string, byte[]> { ["Rechnung.pdf"] = pdf }; byte[] xml = { 0x3C, 0x3F };
var attachments = new Dictionary<string, byte[]> { ["Rechnung R2026-0119.pdf"] = pdf, ["zugferd.xml"] = xml };
bool result = await svc.SendEmailAsync("inv_5", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", attachments); bool result = await svc.SendEmailAsync("inv_map", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", attachments, cc: "cc@example.de");
Assert.True(result); Assert.True(result);
Assert.NotNull(handler.LastRequestBody); var m = Assert.Single(mailer.Pushed);
var json = JObject.Parse(handler.LastRequestBody!); Assert.Equal("e", m.ComType);
var att = (JArray)json["communication"]!["attachments"]!; Assert.Equal("inv_map", m.Reference);
Assert.Single(att); Assert.Equal("kunde@example.de", m.To);
Assert.Equal("Rechnung.pdf", att[0]!["filename"]!.ToString()); Assert.Equal("cc@example.de", m.Cc);
Assert.Equal("application/pdf", att[0]!["mimeType"]!.ToString()); Assert.Equal("Subject", m.Subject);
Assert.Equal(Convert.ToBase64String(pdf), att[0]!["contentBase64"]!.ToString()); Assert.StartsWith("<p>hi</p>", m.Body); // signature is appended after the body
Assert.Null(m.AttachmentCount); // null ⇒ client sends the real count
Assert.Equal(2, m.Attachments.Count);
Assert.Equal(pdf, m.Attachments.Single(a => a.FileName == "Rechnung R2026-0119.pdf").Bytes);
Assert.Equal(xml, m.Attachments.Single(a => a.FileName == "zugferd.xml").Bytes);
}
// ── Response checks ────────────────────────────────────────────────────────
[Theory]
[InlineData(1, 0)]
[InlineData(0, 0)]
public async Task SendEmailAsync_AttachmentsNotConfirmedProcessed_ReturnsFalseAndAuditsCounts(int received, int processed)
{
// A 200 is not enough when files were sent: the Mailer must report every one as processed.
var svc = CreateCapturing(new MailerStub(Accepted(received, processed, "cs-9")));
var att = new Dictionary<string, byte[]> { ["Rechnung.pdf"] = [1, 2, 3] };
bool result = await svc.SendEmailAsync("inv_mismatch", "S", "<p>x</p>", "kunde@example.de", "Kunde", att);
Assert.False(result);
var entry = Assert.Single(svc.Entries);
Assert.False(entry.success);
Assert.Contains(entry.log, l => l.Contains("not all 1 attachment(s)")
&& l.Contains($"attachmentsReceived={received}") && l.Contains($"attachmentsProcessed={processed}"));
Assert.Equal("cs-9", Assert.Single(svc.Guids)); // still traceable at the Mailer
}
[Fact]
public async Task SendEmailAsync_AttachmentsMismatch_IncrementsFailedCounterWithReason()
{
string? reason = null;
using var listener = new MeterListener
{
InstrumentPublished = (inst, l) =>
{
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.emails.failed")
l.EnableMeasurementEvents(inst);
}
};
listener.SetMeasurementEventCallback<long>((_, _, tags, _) =>
{
foreach (var t in tags) if (t.Key == "reason") reason = t.Value?.ToString();
});
listener.Start();
var svc = CreateService(new MailerStub(Accepted(1, 0)));
await svc.SendEmailAsync("inv_mm_metric", "S", "<p>x</p>", "kunde@example.de", "Kunde",
new Dictionary<string, byte[]> { ["a.pdf"] = [1] });
Assert.Equal("attachments-mismatch", reason);
}
[Fact]
public async Task SendEmailAsync_Conflict409_IsSuccessBecauseThePendingDuplicateWillBeSent()
{
// A resend of an identical message while the first is still pending: the Mailer keeps the
// first entry and delivers it — the caller (e.g. req/resend, or a double click) must see a
// successful send, with the deduplication recorded in the audit log.
var svc = CreateCapturing(new MailerStub(Failed(409, "duplicate of pending entry")));
bool result = await svc.SendEmailAsync("inv_dup", "S", "<p>x</p>", "kunde@example.de", "Kunde");
Assert.True(result);
var entry = Assert.Single(svc.Entries);
Assert.True(entry.success);
Assert.Contains(entry.log, l => l.Contains("still-pending") && l.Contains("409") && l.Contains("duplicate of pending entry"));
}
[Fact]
public async Task SendEmailAsync_RefusedByClientPreValidation_ReturnsFalseAndAuditsEachRule()
{
// The client refuses before sending (status 0, no attempt) with "; "-joined rule messages —
// e.g. Fuchs' attachment limits (FuchsMailerClient) or the contract's required fields.
var svc = CreateCapturing(new MailerStub(Refused("Attachment 'big.pdf' is 11,000,000 bytes (max 10,485,760); attachment 'x' has no file extension")));
bool result = await svc.SendEmailAsync("inv_refused", "S", "<p>x</p>", "kunde@example.de", "Kunde",
new Dictionary<string, byte[]> { ["big.pdf"] = [1] });
Assert.False(result);
var entry = Assert.Single(svc.Entries);
Assert.False(entry.success);
Assert.Equal(2, entry.log.Count);
Assert.Contains(entry.log, l => l.StartsWith("Attachment 'big.pdf'"));
Assert.Contains(entry.log, l => l.Contains("no file extension"));
}
[Fact]
public async Task SendEmailAsync_RefusedBecauseClientNotUsable_ReturnsFalseWithClientConfigReason()
{
// Same shape as a validation refusal (status 0, no attempt) but no ArgumentException: the
// client could not resolve its token / options. Must not be reported as caller validation.
string? reason = null;
using var listener = new MeterListener
{
InstrumentPublished = (inst, l) =>
{
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.emails.failed")
l.EnableMeasurementEvents(inst);
}
};
listener.SetMeasurementEventCallback<long>((_, _, tags, _) =>
{
foreach (var t in tags) if (t.Key == "reason") reason = t.Value?.ToString();
});
listener.Start();
var svc = CreateCapturing(new MailerStub(new PWMailerResult<PWMailerPushReceipt>
{
StatusCode = 0, Attempts = 0, Operation = "push_com",
Error = "PWMailer token could not be loaded from Key Vault secret 'Fuchs--Mailer--Token': timeout"
}));
bool result = await svc.SendEmailAsync("inv_cfg", "S", "<p>x</p>", "kunde@example.de", "Kunde");
Assert.False(result);
Assert.Equal("client-config", reason);
var entry = Assert.Single(svc.Entries);
Assert.False(entry.success);
Assert.Contains(entry.log, l => l.StartsWith("Mailer client not usable") && l.Contains("Key Vault"));
} }
// ── SMS ──────────────────────────────────────────────────────────────────── // ── SMS ────────────────────────────────────────────────────────────────────
[Fact] [Fact]
public async Task SendSmsAsync_ValidAndApiOk_ReturnsTrue() public async Task SendSmsAsync_ValidAndApiOk_ReturnsTrueAndPushesComTypeS()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler); var svc = CreateService(mailer);
bool result = await svc.SendSmsAsync("01700000000", "Code 1234"); bool result = await svc.SendSmsAsync("01700000000", "Code 1234");
Assert.True(result); Assert.True(result);
Assert.Equal(1, handler.CallCount); var m = Assert.Single(mailer.Pushed);
Assert.Equal("s", m.ComType);
Assert.Equal("01700000000", m.To);
Assert.Equal("Code 1234", m.Body);
} }
[Fact] [Fact]
public async Task SendSmsAsync_EmptyMobile_ReturnsFalseWithoutCallingApi() public async Task SendSmsAsync_EmptyMobile_ReturnsFalseWithoutCallingMailer()
{ {
var handler = new StubHandler(HttpStatusCode.OK); var mailer = new MailerStub();
var svc = CreateService(handler); var svc = CreateService(mailer);
bool result = await svc.SendSmsAsync("", "Code 1234"); bool result = await svc.SendSmsAsync("", "Code 1234");
Assert.False(result); Assert.False(result);
Assert.Equal(0, handler.CallCount); Assert.Equal(0, mailer.CallCount);
} }
// ── Credential verification (GET /mailer/fc?fn=verify) ───────────────────── // ── Credential verification (client VerifyAsync) ────────────────────────────
private sealed class CapturingRequestHandler : HttpMessageHandler private static PWMailerResult<PWMailerVerifyResult> VerifyResult(bool ok, string account = "acc-123", string? serverId = null) =>
{ new() { StatusCode = 200, Value = new PWMailerVerifyResult(ok, account, serverId), Attempts = 1, Operation = "verify" };
private readonly HttpStatusCode _status;
private readonly string _body;
public HttpRequestMessage? LastRequest { get; private set; }
public CapturingRequestHandler(HttpStatusCode status, string body) { _status = status; _body = body; } private static ProcessWebComService VerifyService(Mock<IPWMailerClientService> mailer, string accountId = "acct", string token = "tok", string serverId = "") =>
new(NullLogger<ProcessWebComService>.Instance, intranet: null!, Settings(true, accountId, token, serverId),
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) Options.Create(new FuchsEmailSettings()), mailer.Object);
{
LastRequest = request;
return Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
}
}
[Fact] [Fact]
public async Task VerifyCredentialsAsync_MailerConfirms_ReturnsOkWithAccount() public async Task VerifyCredentialsAsync_MailerConfirms_ReturnsOkWithAccount()
{ {
var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":true,\"account\":\"acc-123\"}"); var mailer = new Mock<IPWMailerClientService>();
var svc = new ProcessWebComService( mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(true));
NullLogger<ProcessWebComService>.Instance, intranet: null!,
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok" }),
Options.Create(new FuchsEmailSettings()),
new StubHttpClientFactory(handler));
var result = await svc.VerifyCredentialsAsync(); var result = await VerifyService(mailer).VerifyCredentialsAsync();
Assert.True(result.Ok); Assert.True(result.Ok);
Assert.Equal("acc-123", result.Account); Assert.Equal("acc-123", result.Account);
Assert.NotNull(handler.LastRequest); mailer.Verify(m => m.VerifyAsync(null, It.IsAny<CancellationToken>()), Times.Once);
Assert.Equal(HttpMethod.Get, handler.LastRequest!.Method);
Assert.Equal("https://mailer.test/mailer/fc?fn=verify", handler.LastRequest.RequestUri!.ToString());
Assert.Equal("Basic", handler.LastRequest.Headers.Authorization!.Scheme);
} }
[Fact] [Fact]
public async Task VerifyCredentialsAsync_ServerIdConfigured_IsSentAndEchoedOnSuccess() public async Task VerifyCredentialsAsync_ServerIdConfigured_IsSentAndEchoedOnSuccess()
{ {
var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":true,\"account\":\"acc-123\",\"serverid\":\"srv-1\"}"); var mailer = new Mock<IPWMailerClientService>();
var svc = new ProcessWebComService( mailer.Setup(m => m.VerifyAsync("srv-1", It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(true, serverId: "srv-1"));
NullLogger<ProcessWebComService>.Instance, intranet: null!,
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok", ServerId = "srv-1" }),
Options.Create(new FuchsEmailSettings()),
new StubHttpClientFactory(handler));
var result = await svc.VerifyCredentialsAsync(); var result = await VerifyService(mailer, serverId: "srv-1").VerifyCredentialsAsync();
Assert.True(result.Ok); Assert.True(result.Ok);
Assert.Equal("srv-1", result.ServerId); Assert.Equal("srv-1", result.ServerId);
Assert.Equal("https://mailer.test/mailer/fc?fn=verify&serverid=srv-1", handler.LastRequest!.RequestUri!.ToString());
} }
[Fact] [Fact]
public async Task VerifyCredentialsAsync_ServerIdNotOwnedByAccount_ReturnsNotOkWithServerIdDetail() public async Task VerifyCredentialsAsync_ServerIdNotOwnedByAccount_ReturnsNotOkWithServerIdDetail()
{ {
var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":false,\"account\":\"acc-123\",\"serverid\":\"srv-wrong\"}"); var mailer = new Mock<IPWMailerClientService>();
var svc = new ProcessWebComService( mailer.Setup(m => m.VerifyAsync("srv-wrong", It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(false, serverId: "srv-wrong"));
NullLogger<ProcessWebComService>.Instance, intranet: null!,
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok", ServerId = "srv-wrong" }),
Options.Create(new FuchsEmailSettings()),
new StubHttpClientFactory(handler));
var result = await svc.VerifyCredentialsAsync(); var result = await VerifyService(mailer, serverId: "srv-wrong").VerifyCredentialsAsync();
Assert.False(result.Ok); Assert.False(result.Ok);
Assert.Contains("srv-wrong", result.Detail); Assert.Contains("srv-wrong", result.Detail);
@@ -548,44 +697,37 @@ public class ProcessWebComServiceTests
[Fact] [Fact]
public async Task VerifyCredentialsAsync_MailerReturnsOkFalse_ReturnsNotOk() public async Task VerifyCredentialsAsync_MailerReturnsOkFalse_ReturnsNotOk()
{ {
var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":false}"); var mailer = new Mock<IPWMailerClientService>();
var svc = new ProcessWebComService( mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(false));
NullLogger<ProcessWebComService>.Instance, intranet: null!,
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok" }),
Options.Create(new FuchsEmailSettings()),
new StubHttpClientFactory(handler));
var result = await svc.VerifyCredentialsAsync(); var result = await VerifyService(mailer).VerifyCredentialsAsync();
Assert.False(result.Ok); Assert.False(result.Ok);
Assert.Contains("ungültige Zugangsdaten", result.Detail);
} }
[Fact] [Fact]
public async Task VerifyCredentialsAsync_Http401_ReturnsNotOkWithDetail() public async Task VerifyCredentialsAsync_Http401_ReturnsNotOkWithDetail()
{ {
var handler = new CapturingRequestHandler(HttpStatusCode.Unauthorized, "unauthorized"); var mailer = new Mock<IPWMailerClientService>();
var svc = new ProcessWebComService( mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>()))
NullLogger<ProcessWebComService>.Instance, intranet: null!, .ReturnsAsync(new PWMailerResult<PWMailerVerifyResult> { StatusCode = 401, Error = "not authenticated", Attempts = 1 });
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "wrong" }),
Options.Create(new FuchsEmailSettings()),
new StubHttpClientFactory(handler));
var result = await svc.VerifyCredentialsAsync(); var result = await VerifyService(mailer, token: "wrong").VerifyCredentialsAsync();
Assert.False(result.Ok); Assert.False(result.Ok);
Assert.Contains("401", result.Detail); Assert.Contains("401", result.Detail);
Assert.Contains("not authenticated", result.Detail);
} }
[Fact] [Fact]
public async Task VerifyCredentialsAsync_TransportException_ReturnsNotOkWithExceptionDetail() public async Task VerifyCredentialsAsync_TransportError_ReturnsNotOkWithClientError()
{ {
var svc = new ProcessWebComService( var mailer = new Mock<IPWMailerClientService>();
NullLogger<ProcessWebComService>.Instance, intranet: null!, mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>()))
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok" }), .ReturnsAsync(new PWMailerResult<PWMailerVerifyResult> { StatusCode = 0, Error = "simulated transport failure", Attempts = 3, TimedOut = false });
Options.Create(new FuchsEmailSettings()),
new StubHttpClientFactory(new ThrowingHandler()));
var result = await svc.VerifyCredentialsAsync(); var result = await VerifyService(mailer).VerifyCredentialsAsync();
Assert.False(result.Ok); Assert.False(result.Ok);
Assert.Contains("simulated transport failure", result.Detail); Assert.Contains("simulated transport failure", result.Detail);
@@ -594,19 +736,14 @@ public class ProcessWebComServiceTests
[Theory] [Theory]
[InlineData("", "tok")] [InlineData("", "tok")]
[InlineData("acct", "")] [InlineData("acct", "")]
public async Task VerifyCredentialsAsync_CredentialsNotConfigured_ReturnsNotOkWithoutCallingApi(string accountId, string token) public async Task VerifyCredentialsAsync_CredentialsNotConfigured_ReturnsNotOkWithoutCallingClient(string accountId, string token)
{ {
var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":true}"); var mailer = new Mock<IPWMailerClientService>();
var svc = new ProcessWebComService(
NullLogger<ProcessWebComService>.Instance, intranet: null!,
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = accountId, Token = token }),
Options.Create(new FuchsEmailSettings()),
new StubHttpClientFactory(handler));
var result = await svc.VerifyCredentialsAsync(); var result = await VerifyService(mailer, accountId, token).VerifyCredentialsAsync();
Assert.False(result.Ok); Assert.False(result.Ok);
Assert.Null(handler.LastRequest); mailer.Verify(m => m.VerifyAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Never);
} }
// ── Metric emission (performance indicator) ──────────────────────────────── // ── Metric emission (performance indicator) ────────────────────────────────
@@ -625,11 +762,9 @@ public class ProcessWebComServiceTests
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value)); listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
listener.Start(); listener.Start();
var svc = CreateService(new StubHandler(HttpStatusCode.OK)); var svc = CreateService(new MailerStub());
await svc.SendEmailAsync("inv_metric", "S", "<p>x</p>", "kunde@example.de", "Kunde"); await svc.SendEmailAsync("inv_metric", "S", "<p>x</p>", "kunde@example.de", "Kunde");
Assert.True(delta >= 1, "fuchs.emails.sent counter should have been incremented on a successful send."); Assert.True(delta >= 1, "fuchs.emails.sent counter should have been incremented on a successful send.");
} }
private const string FuchsMeterName = "Fuchs.Intranet";
} }
+4 -2
View File
@@ -238,7 +238,8 @@ public class ReminderDraftServiceTests
var result = await svc.FlushToDbAsync(s.Token, "user1", null!); var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
Assert.NotNull(result); Assert.NotNull(result);
Assert.Equal("REM42", result!.Id); Assert.Equal("REM42", result!.Data.Id);
Assert.False(result.Updated);
Assert.False(rem.LastChange); // new draft (no prior RemId) → create Assert.False(rem.LastChange); // new draft (no prior RemId) → create
Assert.Equal("REM42", svc.Get(s.Token)!.RemId); Assert.Equal("REM42", svc.Get(s.Token)!.RemId);
// the email/subject the editor set must reach registration // the email/subject the editor set must reach registration
@@ -255,9 +256,10 @@ public class ReminderDraftServiceTests
payload["remid"] = "REM7"; payload["remid"] = "REM7";
var s = svc.OpenFromPayload(payload, "user1"); var s = svc.OpenFromPayload(payload, "user1");
await svc.FlushToDbAsync(s.Token, "user1", null!); var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
Assert.True(rem.LastChange); // prior RemId → update path Assert.True(rem.LastChange); // prior RemId → update path
Assert.True(result!.Updated);
} }
[Fact] [Fact]
+50
View File
@@ -0,0 +1,50 @@
using System;
using Fuchs.Services;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// The reminder save batch (<see cref="ReminderService.BuildRegisterReminderSql"/>): a new draft is
/// created, a re-saved one is updated in place via <c>fds__setReminder</c> — previously the update
/// path ran an empty batch, so "Zwischenspeichern" of an already-registered reminder draft silently
/// saved nothing. Either proc leaving <c>@Id</c> NULL must surface as an error, not as an empty row.
/// </summary>
public class ReminderRegisterSqlTests
{
[Theory]
[InlineData(false, "[dbo].[fds__createReminder] @InvId,", "[dbo].[fds__setReminder]")]
[InlineData(true, "[dbo].[fds__setReminder] @RemId,", "[dbo].[fds__createReminder]")]
public void BuildRegisterReminderSql_ExactlyOneProcPerMode(bool change, string expectedProc, string otherProc)
{
string sql = ReminderService.BuildRegisterReminderSql(change);
Assert.Contains(expectedProc, sql);
Assert.DoesNotContain(otherProc, sql);
Assert.Equal(change, sql.Contains("@RemId"));
Assert.Equal(!change, sql.Contains("@InvId"));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void BuildRegisterReminderSql_GuardFollowsTheProc(bool change)
{
string sql = ReminderService.BuildRegisterReminderSql(change);
int proc = sql.IndexOf("EXECUTE [dbo].[fds__", StringComparison.Ordinal);
int guard = sql.IndexOf("IF @Id IS NULL THROW 50001", StringComparison.Ordinal);
Assert.True(proc >= 0 && guard > proc, sql);
Assert.StartsWith("DECLARE @Id varchar(10);", sql);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void BuildRegisterReminderSql_EveryStatementIsSemicolonTerminated(bool change)
{
// THROW requires the preceding statement to be terminated with ';'.
foreach (var line in ReminderService.BuildRegisterReminderSql(change).Split('\n'))
Assert.EndsWith(";", line.TrimEnd());
}
}
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Notifications;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
using Moq;
using OCORE.security;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Reminder counterpart of <see cref="InvoiceServiceStoreFileTests"/>: every failure branch of
/// <see cref="ReminderService.StoreReminderDocumentFileAsync"/> must be logged individually and
/// must not reach the blob upload or the ReminderFileCreated event (ADR 0025).
/// </summary>
public class ReminderServiceStoreFileTests
{
private sealed class RecordingLogger : ILogger<ReminderService>
{
public sealed record Entry(LogLevel Level, Exception? Exception, string Message);
public readonly List<Entry> Entries = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add(new Entry(logLevel, exception, formatter(state, exception)));
}
private const string UnreachableConnectionString =
"Server=127.0.0.1,1;Database=fuchs;User Id=u;Password=p;Connect Timeout=1;TrustServerCertificate=True";
private static (ReminderService svc, RecordingLogger log, Mock<IBlobStorageService> blob, Mock<IEventService> events)
MakeService(Mock<IPdfService> pdf, string? connectionString = null)
{
var log = new RecordingLogger();
var blob = new Mock<IBlobStorageService>();
var events = new Mock<IEventService>();
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:fuchs_fds_ConnectionString"] = connectionString
}).Build();
var svc = new ReminderService(new Fuchs_intranet(config), pdf.Object, blob.Object, events.Object, log);
return (svc, log, blob, events);
}
private static FdsReminderData Reminder() => new()
{
ReminderRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>
{
["Id"] = "M1ABC",
["subject"] = "Zahlungserinnerung",
["IsFinal"] = true
}),
IsDraft = false
};
private static Mock<IPdfService> PdfReturning(byte[] bytes)
{
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.ReturnsAsync(new Document());
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<Document>())).Returns(bytes);
return pdf;
}
private static void AssertNothingPersisted(Mock<IBlobStorageService> blob, Mock<IEventService> events)
{
blob.Verify(b => b.UploadReminderPdfAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<byte[]>(),
It.IsAny<IReadOnlyDictionary<string, object?>>(), It.IsAny<CancellationToken>()), Times.Never);
events.Verify(e => e.ReminderFileCreatedAsync(It.IsAny<FdsReminderData>(), It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task StoreReminderDocumentFileAsync_RenderProducesZeroBytes_ReturnsEmptyAndLogsError()
{
var (svc, log, blob, events) = MakeService(PdfReturning(Array.Empty<byte>()));
var result = await svc.StoreReminderDocumentFileAsync(Reminder(), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
Assert.Null(entry.Exception);
Assert.Contains("produced 0 bytes", entry.Message);
Assert.Contains("M1ABC", entry.Message);
AssertNothingPersisted(blob, events);
}
[Fact]
public async Task StoreReminderDocumentFileAsync_RenderThrows_ReturnsEmptyAndLogsExceptionOnce()
{
var pdf = new Mock<IPdfService>();
var boom = new InvalidOperationException("render exploded");
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>())).ThrowsAsync(boom);
var (svc, log, blob, events) = MakeService(pdf);
var result = await svc.StoreReminderDocumentFileAsync(Reminder(), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
Assert.Same(boom, entry.Exception);
Assert.Contains("render failed", entry.Message);
AssertNothingPersisted(blob, events);
}
[Fact]
public async Task StoreReminderDocumentFileAsync_RenderOkButDbStoreFails_ReturnsEmptyAndSkipsBlob()
{
var (svc, log, blob, events) = MakeService(PdfReturning(new byte[] { 0x25, 0x50, 0x44, 0x46 }), UnreachableConnectionString);
var result = await svc.StoreReminderDocumentFileAsync(Reminder(), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("produced 0 bytes"));
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("render failed"));
AssertNothingPersisted(blob, events);
}
}
+84
View File
@@ -0,0 +1,84 @@
using Fuchs.Controllers;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// <see cref="IntranetController.SafeDocumentFileName"/> names a regenerated invoice document
/// (inv/rdoc, ADR 0025). DocumentName comes from the user-typed invoice title, so it must survive
/// path separators and editor markup without truncating or breaking the download filename.
/// </summary>
public class ReportDocFileNameTests
{
[Theory]
[InlineData("Rechnung R2026-0258.pdf", "Rechnung R2026-0258.pdf")]
[InlineData("Wartung 2025/26 R2026-0001.pdf", "Wartung 2025_26 R2026-0001.pdf")]
[InlineData("Angebot A\\B: \"x\" <y>|z?*.pdf", "Angebot A_B_ _x_ _y__z__.pdf")]
[InlineData("<p>Rechnung R2026-0002.pdf</p>", "Rechnung R2026-0002.pdf")]
[InlineData(" Rechnung R2026-0003.pdf ", "Rechnung R2026-0003.pdf")]
public void SafeDocumentFileName_ReplacesInvalidCharsAndStripsMarkup(string documentName, string expected)
{
Assert.Equal(expected, IntranetController.SafeDocumentFileName(documentName, "Rechnung_X.pdf"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("<p></p>")]
[InlineData("<p> </p>")]
public void SafeDocumentFileName_EmptyDocumentName_UsesFallback(string? documentName)
{
Assert.Equal("Rechnung_W7VAL.pdf", IntranetController.SafeDocumentFileName(documentName!, "Rechnung_W7VAL.pdf"));
}
/// <summary>
/// The stored-document path (<c>FdsMfr.GetReportDoc</c>) replaced exactly these characters
/// before the sanitiser was shared: <c>= &gt; | ! &lt; /</c>. The shared helper must still
/// produce the identical result for them — unifying the two paths may not loosen either.
/// </summary>
[Theory]
[InlineData("a>b|c!d<e/f=g.pdf", "a_b_c_d_e_f_g.pdf")]
[InlineData("<p>Summe=100!.pdf</p>", "Summe_100_.pdf")]
public void SafeFileName_KeepsEveryReplacementTheStoredDocumentPathMade(string documentName, string expected)
{
Assert.Equal(expected, fds.FdsDocumentNames.SafeFileName(documentName, "X.pdf"));
}
/// <summary>
/// Characters the old stored-document path did NOT catch but Windows rejects — the reason
/// the sanitiser was unified (code review on ADR 0025): a stored and a regenerated document
/// must never diverge in filename safety.
/// </summary>
[Theory]
[InlineData("Angebot A\\B.pdf", "Angebot A_B.pdf")]
[InlineData("Re: 2026.pdf", "Re_ 2026.pdf")]
[InlineData("x*y?z\".pdf", "x_y_z_.pdf")]
[InlineData("tab\there.pdf", "tab_here.pdf")]
public void SafeFileName_ReplacesInvalidCharsTheStoredDocumentPathMissed(string documentName, string expected)
{
Assert.Equal(expected, fds.FdsDocumentNames.SafeFileName(documentName, "X.pdf"));
}
[Theory]
[InlineData("Rechnung R2026-0258.pdf")]
[InlineData("Wartung 2025/26 R2026-0001.pdf")]
[InlineData("a>b|c!d<e/f=g.pdf")]
[InlineData("Angebot A\\B: \"x\" <y>|z?*.pdf")]
[InlineData("<p></p>")]
[InlineData(null)]
public void SafeDocumentFileName_MatchesSharedSanitiserForEveryInput(string? documentName)
{
Assert.Equal(
fds.FdsDocumentNames.SafeFileName(documentName, "Rechnung_X.pdf"),
IntranetController.SafeDocumentFileName(documentName!, "Rechnung_X.pdf"));
}
[Fact]
public void SafeFileName_ReturnsFallbackVerbatim_WhenNothingUsableRemains()
{
// The fallback is the caller's responsibility and is not sanitised or second-guessed.
Assert.Equal("W7VAL", fds.FdsDocumentNames.SafeFileName("<p></p>", "W7VAL"));
Assert.Equal("", fds.FdsDocumentNames.SafeFileName("", ""));
}
}
@@ -0,0 +1,76 @@
using System.Security.Claims;
using Fuchs.intranet;
using Fuchs.Logging;
using Microsoft.AspNetCore.Http;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Covers <see cref="RequestLogScopeMiddleware.BuildScope"/>: the per-request scope that attributes
/// every log line (including ones written deep inside OCORE) to a request id, path, and — once
/// authenticated — the <c>fuchs:useraccount_id</c> claim, feeding both the generic serialized
/// context and (via the "authuser" key specifically) <c>LogEntry.AuthUser</c>. See ADR 0024.
/// </summary>
public class RequestLogScopeMiddlewareTests
{
private static DefaultHttpContext ContextWithPath(string path)
{
var ctx = new DefaultHttpContext();
ctx.Request.Path = path;
return ctx;
}
[Fact]
public void BuildScope_UnauthenticatedRequest_CarriesRequestIdAndPath_NoAuthUserKey()
{
var ctx = ContextWithPath("/do/inv/dpreview");
ctx.TraceIdentifier = "trace-123";
var scope = RequestLogScopeMiddleware.BuildScope(ctx);
Assert.Equal("trace-123", scope["requestId"]);
Assert.Equal("/do/inv/dpreview", scope["path"]);
Assert.False(scope.ContainsKey("authuser"));
}
[Fact]
public void BuildScope_AuthenticatedRequest_IncludesAuthUserFromClaim()
{
var ctx = ContextWithPath("/do/inv/dpreview");
var identity = new ClaimsIdentity(new[]
{
new Claim(FuchsUserIdentity.ClaimUserAccountId, "acct-42"),
}, "fuchs_intranet");
ctx.User = new ClaimsPrincipal(identity);
var scope = RequestLogScopeMiddleware.BuildScope(ctx);
Assert.Equal("acct-42", scope["authuser"]);
}
[Fact]
public void BuildScope_AuthenticatedPrincipalWithEmptyClaim_OmitsAuthUserKey()
{
var ctx = ContextWithPath("/do/inv/dpreview");
var identity = new ClaimsIdentity(new[]
{
new Claim(FuchsUserIdentity.ClaimUserAccountId, ""),
}, "fuchs_intranet");
ctx.User = new ClaimsPrincipal(identity);
var scope = RequestLogScopeMiddleware.BuildScope(ctx);
Assert.False(scope.ContainsKey("authuser"));
}
[Fact]
public void BuildScope_RootPath_UsesEmptyStringNotNull()
{
var ctx = new DefaultHttpContext();
var scope = RequestLogScopeMiddleware.BuildScope(ctx);
Assert.Equal("", scope["path"]);
}
}
+298
View File
@@ -0,0 +1,298 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// The C# ↔ T-SQL contract, checked against the <c>Fuchs_Database</c> SSDT project (ADR 0036).
/// Every <c>[dbo].[…]</c> object named in a C# string must exist in the project, every
/// <c>EXECUTE</c> must bind against the procedure's declared parameters, and every scalar/table
/// function call must pass the declared number of arguments. None of this is visible to the
/// compiler or to a unit test that stubs the database — it used to fail only at runtime
/// (e.g. <c>inv/datev</c> passing three positional arguments to a four-parameter proc).
/// The SSDT build covers the SQL side of the same contract (unresolved/ambiguous columns).
/// </summary>
public class SqlContractTests
{
internal sealed record SqlObject(string Name, string Kind, IReadOnlyList<(string Name, bool HasDefault)> Parameters);
internal sealed record SqlReference(string File, int Line, string Name, bool IsExecute, bool IsCall, string Args);
private const char JoinedBreak = '\u0001';
private static readonly string[] ScannedProjects = ["Fuchs", "Fuchs_DataService"];
// Signature FdsMfrClient.Update__entitytable uses for its dynamically named [mfr__updt__<table>] call.
private static readonly string[] MfrUpdtSignature = ["@tblname", "@referencetable", "@tgtid"];
private static readonly Regex Declaration = new(
@"CREATE\s+(?:OR\s+ALTER\s+)?(?<kind>PROCEDURE|PROC|FUNCTION|TYPE|TABLE|VIEW)\s+(?:\[?dbo\]?\.)?\[?(?<name>[^\]\s(]+)\]?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex ParameterDeclaration = new(
@"@(?<name>\w+)\s+(?:AS\s+)?[\w\[\]\.]+(?:\s*\([^)]*\))?(?:\s+READONLY)?\s*(?<def>=)?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex Reference = new(
@"(?<exec>EXEC(?:UTE)?\s+)?(?:@\w+\s*=\s*)?\[?dbo\]?\.\[?(?<name>[A-Za-z][A-Za-z0-9_\-]*)\]?(?<call>\s*\()?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// ── Locating and parsing ──────────────────────────────────────────────────────────────────
private static string RepoRoot()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
if (Directory.Exists(Path.Combine(dir.FullName, "Fuchs_Database", "dbo"))
&& Directory.Exists(Path.Combine(dir.FullName, "Fuchs")))
return dir.FullName;
Assert.Skip("Fuchs_Database SSDT project not present in this checkout.");
return "";
}
internal static string StripSqlComments(string sql) =>
Regex.Replace(Regex.Replace(sql, @"/\*.*?\*/", " ", RegexOptions.Singleline), @"--[^\n]*", " ");
/// <summary>Parses one SSDT file into the object it declares, or null for non-object scripts.</summary>
internal static SqlObject? ParseObject(string sql)
{
sql = StripSqlComments(sql);
var m = Declaration.Match(sql);
if (!m.Success) return null;
string kind = m.Groups["kind"].Value.ToUpperInvariant() is "PROC" ? "PROCEDURE" : m.Groups["kind"].Value.ToUpperInvariant();
string header = "";
string rest = sql[(m.Index + m.Length)..];
if (kind == "FUNCTION")
{
int open = rest.IndexOf('('), depth = 0;
for (int i = open; open >= 0 && i < rest.Length; i++)
{
depth += rest[i] == '(' ? 1 : rest[i] == ')' ? -1 : 0;
if (depth == 0) { header = rest[(open + 1)..i]; break; }
}
}
else if (kind == "PROCEDURE")
{
header = Regex.Split(rest, @"^\s*AS\b", RegexOptions.Multiline | RegexOptions.IgnoreCase)[0];
header = Regex.Split(header, @"\bWITH\s+(?:EXECUTE|RECOMPILE|ENCRYPTION)", RegexOptions.IgnoreCase)[0];
}
var ps = ParameterDeclaration.Matches(header)
.Select(p => ("@" + p.Groups["name"].Value.ToLowerInvariant(), p.Groups["def"].Success))
.ToList();
return new SqlObject(m.Groups["name"].Value, kind, ps);
}
private static Dictionary<string, SqlObject> LoadProject()
{
var objects = new Dictionary<string, SqlObject>(StringComparer.OrdinalIgnoreCase);
foreach (var file in Directory.EnumerateFiles(Path.Combine(RepoRoot(), "Fuchs_Database", "dbo"), "*.sql", SearchOption.AllDirectories))
if (ParseObject(File.ReadAllText(file)) is { } o)
objects[o.Name] = o;
return objects;
}
/// <summary>
/// SQL object references in C# source. Comment lines are dropped (commented-out code and doc
/// comments name objects too) and adjacent concatenated literals are joined, so an
/// EXECUTE split over <c>"…" +</c> lines is seen as one statement.
/// </summary>
internal static IEnumerable<SqlReference> ScanSource(string file, string source)
{
var lines = source.Split('\n');
string code = string.Join('\n', lines.Select(l => l.TrimStart().StartsWith("//", StringComparison.Ordinal) ? "" : l));
code = Regex.Replace(code, @"/\*.*?\*/", m => new string('\n', m.Value.Count(c => c == '\n')), RegexOptions.Singleline);
// Join "a " + "b". The swallowed line breaks become JoinedBreak: it counts as a line for the
// reported line number but does not end the statement's argument list.
code = Regex.Replace(code, "\"(\\s*\\+\\s*)\\$?@?\"", m => new string(JoinedBreak, m.Groups[1].Value.Count(c => c == '\n')));
foreach (Match m in Reference.Matches(code))
{
string name = m.Groups["name"].Value;
if (name.StartsWith("sp_", StringComparison.OrdinalIgnoreCase) || name.StartsWith("sys", StringComparison.OrdinalIgnoreCase))
continue;
int line = code.Take(m.Index).Count(c => c is '\n' or JoinedBreak) + 1;
string tail = code[(m.Index + m.Length)..];
string args;
if (m.Groups["call"].Success)
{
int depth = 1, i = 0;
while (i < tail.Length && depth > 0) depth += tail[i++] switch { '(' => 1, ')' => -1, _ => 0 };
args = tail[..Math.Max(0, i - 1)];
}
else
{
int end = tail.IndexOfAny([';', '"', '\n']);
args = end < 0 ? tail : tail[..end];
}
args = args.Replace(JoinedBreak.ToString(), "", StringComparison.Ordinal).Trim();
yield return new SqlReference(file, line, name, m.Groups["exec"].Success, m.Groups["call"].Success, args);
}
}
private static List<SqlReference> ScanRepository()
{
string root = RepoRoot();
return ScannedProjects
.SelectMany(p => Directory.EnumerateFiles(Path.Combine(root, p), "*.cs", SearchOption.AllDirectories))
.Where(f => !Regex.IsMatch(f, @"[\\/](bin|obj)[\\/]"))
.SelectMany(f => ScanSource(Path.GetRelativePath(root, f), File.ReadAllText(f)))
.ToList();
}
// ── Binding rules ─────────────────────────────────────────────────────────────────────────
/// <summary>Binding problems of an EXECUTE argument list against a procedure's parameters.</summary>
internal static List<string> ExecuteProblems(string args, IReadOnlyList<(string Name, bool HasDefault)> ps)
{
var problems = new List<string>();
var supplied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
bool sawNamed = false;
int position = 0;
foreach (var arg in args.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
var named = Regex.Match(arg, @"^(@\w+)\s*=");
if (named.Success)
{
sawNamed = true;
string n = named.Groups[1].Value.ToLowerInvariant();
if (!ps.Any(p => p.Name == n)) problems.Add($"unknown parameter {n}");
supplied.Add(n);
continue;
}
if (sawNamed) problems.Add($"positional argument '{arg}' after a named one");
if (position >= ps.Count) { problems.Add($"argument '{arg}' beyond the {ps.Count} declared parameter(s)"); continue; }
supplied.Add(ps[position++].Name);
}
problems.AddRange(ps.Where(p => !p.HasDefault && !supplied.Contains(p.Name))
.Select(p => $"required parameter {p.Name} not supplied"));
return problems;
}
/// <summary>T-SQL functions take every declared argument (DEFAULT is passed explicitly).</summary>
internal static List<string> CallProblems(string args, IReadOnlyList<(string Name, bool HasDefault)> ps)
{
int count = string.IsNullOrWhiteSpace(args) ? 0 : Regex.Split(args, @",(?![^(]*\))").Length;
return count == ps.Count ? [] : [$"{count} argument(s), function declares {ps.Count}"];
}
internal static List<string> Problems(IEnumerable<SqlReference> refs, IReadOnlyDictionary<string, SqlObject> objects)
{
var problems = new List<string>();
foreach (var r in refs)
{
string at = $"{r.File}:{r.Line} [dbo].[{r.Name}]";
if (!objects.TryGetValue(r.Name, out var o)) { problems.Add($"{at}: not in Fuchs_Database"); continue; }
var found = r.IsExecute && o.Kind == "PROCEDURE" ? ExecuteProblems(r.Args, o.Parameters)
: r.IsCall && o.Kind == "FUNCTION" ? CallProblems(r.Args, o.Parameters)
: [];
problems.AddRange(found.Select(p => $"{at}: {p}"));
}
return problems;
}
// ── The contract, against the real repository ─────────────────────────────────────────────
[Fact]
public void ScanRepository_RealSources_FindsTheKnownCallSites()
{
// Guards the scanner itself: a regex regression that matched nothing would pass every check below.
var refs = ScanRepository();
Assert.True(refs.Count(r => r.IsExecute) >= 50, $"only {refs.Count(r => r.IsExecute)} EXECUTE call sites found");
Assert.Contains(refs, r => r.IsExecute && r.Name.Equals("fds__getDatevExports", StringComparison.OrdinalIgnoreCase));
Assert.Contains(refs, r => r.IsCall && r.Name.Equals("fis_getModuleAuth", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void CSharpSqlReferences_AgainstSsdtProject_ExistAndBind()
{
var problems = Problems(ScanRepository(), LoadProject());
Assert.True(problems.Count == 0, "C# ↔ SQL contract violations:\n" + string.Join('\n', problems));
}
[Fact]
public void MfrUpdtProcedures_AllDeclareTheSignatureFdsMfrClientCallsDynamically()
{
var updt = LoadProject().Values.Where(o => o.Name.StartsWith("mfr__updt__", StringComparison.OrdinalIgnoreCase)).ToList();
Assert.NotEmpty(updt);
Assert.All(updt, o => Assert.Equal(MfrUpdtSignature, o.Parameters.Select(p => p.Name)));
}
[Fact]
public void LoggerSqlSinkProcedure_ExistsInSsdtProject()
// Program.cs configures the OCORE MultiLogger sink by name (ADR 0024); a missing proc silently loses every DB log row.
=> Assert.True(LoadProject().TryGetValue("fds__admin_logdebug_ext", out var o) && o.Kind == "PROCEDURE");
// ── The rules themselves: succeeding and failing inputs ───────────────────────────────────
private static readonly (string, bool)[] Datev = [("@tgtdate", false), ("@mode", true), ("@files", false), ("@authuser", false)];
[Theory]
[InlineData("@tgtdate = @tgtdate, @files = 0, @authuser = @authuser")]
[InlineData("@tgtdate, @mode, @files, @authuser")]
[InlineData("@tgtdate, @mode, @files = 1, @authuser = @u")]
public void ExecuteProblems_CorrectlyBoundArguments_None(string args)
=> Assert.Empty(ExecuteProblems(args, Datev));
[Theory]
[InlineData("@tgtdate, @mode, @authuser", "required parameter @authuser")] // the former inv/datev call
[InlineData("@tgtdate = @t, @files = 0, @user = @u", "unknown parameter @user")]
[InlineData("@tgtdate = @t, @mode, @files = 0, @authuser = @u", "after a named one")]
[InlineData("@a, @b, @c, @d, @e", "beyond the 4 declared")]
[InlineData("", "required parameter @tgtdate")]
public void ExecuteProblems_MisboundArguments_Reported(string args, string expected)
=> Assert.Contains(ExecuteProblems(args, Datev), p => p.Contains(expected, StringComparison.Ordinal));
[Theory]
[InlineData("'fds_reports', @authuser", 2, true)]
[InlineData("LEFT(@a, 3), @b", 2, true)]
[InlineData("", 0, true)]
[InlineData("@authuser", 2, false)]
[InlineData("@a, @b, @c", 2, false)]
public void CallProblems_ArgumentCount_ReportedOnlyOnMismatch(string args, int declared, bool ok)
{
var ps = Enumerable.Range(0, declared).Select(i => ($"@p{i}", false)).ToList();
Assert.Equal(ok, CallProblems(args, ps).Count == 0);
}
[Fact]
public void ScanSource_ConcatenatedCommentedAndDocText_OnlyLiveStatementsWithFullArguments()
{
const string src = """
var a = "EXECUTE [dbo].[proc_a] @x, " +
"@y;";
// "EXECUTE [dbo].[proc_commented] @z;"
/// <c>[dbo].[proc_doc]</c>
var b = "SELECT [dbo].[fn_b](@p, LEFT(@q, 2)) AS v";
""";
var refs = ScanSource("x.cs", src).ToList();
Assert.Equal(["proc_a", "fn_b"], refs.Select(r => r.Name));
Assert.Equal("@x, @y", refs[0].Args);
Assert.Equal(1, refs[0].Line);
Assert.Equal("@p, LEFT(@q, 2)", refs[1].Args);
Assert.Equal(5, refs[1].Line);
}
[Fact]
public void Problems_ObjectMissingFromProject_Reported()
{
var objects = new Dictionary<string, SqlObject>(StringComparer.OrdinalIgnoreCase);
var refs = new[] { new SqlReference("x.cs", 3, "fds__gone", true, false, "@a") };
Assert.Equal(["x.cs:3 [dbo].[fds__gone]: not in Fuchs_Database"], Problems(refs, objects));
}
[Theory]
[InlineData("CREATE PROCEDURE [dbo].[p]\n\t@a as nvarchar(50)\n\t, @b int = 1 -- @c comment\nAS\nBEGIN SELECT 1 END", "PROCEDURE", "@a,@b", "False,True")]
[InlineData("create function [f](@count int = 10)\nRETURNS TABLE AS RETURN SELECT 1 as x", "FUNCTION", "@count", "True")]
[InlineData("CREATE FUNCTION [dbo].[g] (\n)\nRETURNS varchar(10) AS BEGIN RETURN '' END", "FUNCTION", "", "")]
[InlineData("CREATE TYPE [dbo].[tt] AS TABLE ([Id] int)", "TYPE", "", "")]
public void ParseObject_SsdtDeclarations_NameKindAndParameters(string sql, string kind, string names, string defaults)
{
var o = ParseObject(sql)!;
Assert.Equal(kind, o.Kind);
Assert.Equal(names, string.Join(",", o.Parameters.Select(p => p.Name)));
Assert.Equal(defaults, string.Join(",", o.Parameters.Select(p => p.HasDefault)));
}
}
+86
View File
@@ -0,0 +1,86 @@
using System;
using System.Globalization;
using System.Linq;
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
using Xunit;
using static OCORE.SQL.sql;
namespace Fuchs.Tests;
/// <summary>
/// Regression tests for the OCORE string → number SQL parameter helpers. Saving a Stornorechnung
/// failed in production with "Error converting data type float to numeric": the negative totals
/// did not parse in the first (sign-less) attempt and fell back to the server's de-DE culture,
/// where "." is a group separator — "-19.95" became -1995 and JS float noise
/// ("-1234.5600000000002") overflowed numeric(10,3), so fds__setInvoice never ran.
/// Every test runs under de-DE, the production server culture.
/// </summary>
public class SqlNumberParameterTests : IDisposable
{
private readonly CultureInfo _culture = CultureInfo.CurrentCulture;
public SqlNumberParameterTests() => CultureInfo.CurrentCulture = new CultureInfo("de-DE");
public void Dispose() => CultureInfo.CurrentCulture = _culture;
[Theory]
[InlineData("-19.95", -19.95)]
[InlineData("-1234.56", -1234.56)]
[InlineData("-1234.5600000000002", -1234.56)]
[InlineData("-0.5", -0.5)]
[InlineData("1234.56", 1234.56)]
[InlineData("1234.5600000000002", 1234.56)]
[InlineData("-1,5", -1.5)]
[InlineData("-1.234,56", -1234.56)]
[InlineData("1.234,56", 1234.56)]
[InlineData("1,234.56", 1234.56)]
[InlineData(" -42 ", -42)]
[InlineData("-1.5E-05", -0.000015)]
[InlineData("0", 0)]
public void SQL_Float_StringValue_ParsesSignedAndCultureFormattedNumbers(string input, double expected)
{
var p = SQL_Float("@v", stringvalue: input);
var value = Assert.IsType<float>(p.Value);
Assert.Equal(expected, value, 0.01);
}
[Theory]
[InlineData("-19.95", -19.95)]
[InlineData("-1234.56", -1234.56)]
[InlineData("-1.234,56", -1234.56)]
[InlineData("1.234,56", 1234.56)]
[InlineData("-1,5", -1.5)]
public void SQL_Decimal_StringValue_ParsesSignedAndCultureFormattedNumbers(string input, double expected)
{
var p = SQL_Decimal("@v", input, precision: 10, scale: 3);
var value = Assert.IsType<decimal>(p.Value);
Assert.Equal((decimal)expected, value);
}
[Theory]
[InlineData("")]
[InlineData("NaN")]
[InlineData("abc")]
[InlineData("--5")]
public void SQL_Float_StringValue_InvalidOrEmpty_IsDbNull(string input)
{
var p = SQL_Float("@v", stringvalue: input);
Assert.Equal(DBNull.Value, p.Value);
}
[Theory]
[InlineData("")]
[InlineData("NaN")]
[InlineData("abc")]
public void SQL_Decimal_StringValue_InvalidOrEmpty_IsDbNull(string input)
{
var p = SQL_Decimal("@v", input);
Assert.Equal(DBNull.Value, p.Value);
}
}
@@ -0,0 +1,18 @@
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Groups every test class that saves/mutates/restores the process-wide static
/// <see cref="OCORE.Logging.Logger"/> field (e.g. to exercise the <c>FIS_SQLOptions</c>/
/// <c>FdsSqlOptions</c> "no logger given" fallback). xUnit runs different test *classes* in
/// parallel by default (each is its own collection unless grouped); without this, two such tests
/// racing on the same static field could read/restore each other's value and fail intermittently
/// for reasons unrelated to the code under test. Putting them in one named collection makes xUnit
/// run them sequentially relative to each other while leaving the rest of the suite parallel.
/// </summary>
[CollectionDefinition(Name)]
public class StaticOcoreLoggerCollection
{
public const string Name = "OCORE.Logging.Logger (static)";
}
+148
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics.Metrics; using System.Diagnostics.Metrics;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Services; using Fuchs.Services;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -24,6 +25,7 @@ namespace Fuchs.Tests;
public class SystemStatusServiceTests public class SystemStatusServiceTests
{ {
private const string FuchsMeterName = "Fuchs.Intranet"; private const string FuchsMeterName = "Fuchs.Intranet";
private static readonly byte[] FakePdfBytes = { 0x25, 0x50, 0x44, 0x46, 0x2D }; // "%PDF-"
private static IConfiguration Config(Dictionary<string, string?>? overrides = null) private static IConfiguration Config(Dictionary<string, string?>? overrides = null)
{ {
@@ -51,6 +53,7 @@ public class SystemStatusServiceTests
Mock<IBlobStorageService>? blob = null, Mock<IBlobStorageService>? blob = null,
Mock<IMfrClientFactory>? mfr = null, Mock<IMfrClientFactory>? mfr = null,
Mock<IComService>? com = null, Mock<IComService>? com = null,
Mock<IPdfService>? pdf = null,
ProcessWebComSettings? mailer = null, ProcessWebComSettings? mailer = null,
FuchsEmailSettings? email = null, FuchsEmailSettings? email = null,
AzureBlobStorageSettings? blobSettings = null, AzureBlobStorageSettings? blobSettings = null,
@@ -67,6 +70,16 @@ public class SystemStatusServiceTests
.ReturnsAsync(new MailerCredentialsCheckResult { Ok = true, Account = "acc" }); .ReturnsAsync(new MailerCredentialsCheckResult { Ok = true, Account = "acc" });
} }
// Default PDF stub: renders any letter to a fixed, recognisable byte payload.
var pdfMock = pdf ?? new Mock<IPdfService>();
if (pdf is null)
{
pdfMock.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.ReturnsAsync(new MigraDoc.DocumentObjectModel.Document());
pdfMock.Setup(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>()))
.Returns(FakePdfBytes);
}
return new SystemStatusService( return new SystemStatusService(
config ?? Config(), config ?? Config(),
env.Object, env.Object,
@@ -74,6 +87,7 @@ public class SystemStatusServiceTests
(blob ?? new Mock<IBlobStorageService>()).Object, (blob ?? new Mock<IBlobStorageService>()).Object,
(mfr ?? new Mock<IMfrClientFactory>()).Object, (mfr ?? new Mock<IMfrClientFactory>()).Object,
comMock.Object, comMock.Object,
pdfMock.Object,
Options.Create(mailer ?? new ProcessWebComSettings { Enabled = true, Token = "tok", BaseUrl = "https://api.example", AccountId = "acc", ServerId = "srv" }), Options.Create(mailer ?? new ProcessWebComSettings { Enabled = true, Token = "tok", BaseUrl = "https://api.example", AccountId = "acc", ServerId = "srv" }),
Options.Create(email ?? new FuchsEmailSettings()), Options.Create(email ?? new FuchsEmailSettings()),
Options.Create(blobSettings ?? new AzureBlobStorageSettings { Enabled = true }), Options.Create(blobSettings ?? new AzureBlobStorageSettings { Enabled = true }),
@@ -495,6 +509,140 @@ public class SystemStatusServiceTests
Assert.Null(r.OverrideRecipient); Assert.Null(r.OverrideRecipient);
} }
[Fact]
public async Task SendTestEmailAsync_AttachPdfDefault_AttachesRenderedPdfUnderFixedName()
{
Dictionary<string, byte[]>? capturedAttachments = null;
FuchsPdf.FdsTextBlocks? capturedBlocks = null;
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
(_, _, _, _, _, att, _) => capturedAttachments = att)
.ReturnsAsync(true);
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), false))
.Callback<FuchsPdf.FdsTextBlocks, bool>((tb, _) => capturedBlocks = tb)
.ReturnsAsync(new MigraDoc.DocumentObjectModel.Document());
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>())).Returns(FakePdfBytes);
var svc = Build(com: com, pdf: pdf);
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Zeile1\r\nZeile2");
Assert.True(r.Sent);
Assert.Equal(SystemStatusService.TestPdfAttachmentName, r.AttachmentName);
Assert.Contains(SystemStatusService.TestPdfAttachmentName, r.Message);
Assert.NotNull(capturedAttachments);
var (name, bytes) = Assert.Single(capturedAttachments!);
Assert.Equal(SystemStatusService.TestPdfAttachmentName, name);
Assert.Equal(FakePdfBytes, bytes);
// The letter carries the mail's own subject/body and addresses the requested recipient.
Assert.NotNull(capturedBlocks);
Assert.Equal("Betreff", capturedBlocks!.Subject);
Assert.Equal("Zeile1\nZeile2", capturedBlocks.Body);
Assert.Contains("dest@example.test", capturedBlocks.Address);
pdf.Verify(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>()), Times.Once);
}
[Fact]
public async Task SendTestEmailAsync_AttachPdfFalse_SendsWithoutAttachmentAndSkipsRendering()
{
Dictionary<string, byte[]>? capturedAttachments = new();
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
(_, _, _, _, _, att, _) => capturedAttachments = att)
.ReturnsAsync(true);
var pdf = new Mock<IPdfService>(MockBehavior.Strict); // any render call would throw
var svc = Build(com: com, pdf: pdf);
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body", attachPdf: false);
Assert.True(r.Sent);
Assert.Null(r.AttachmentName);
Assert.Null(capturedAttachments);
Assert.DoesNotContain("PDF", r.Message);
}
[Fact]
public async Task SendTestEmailAsync_PdfRenderingFails_DoesNotSendAndReportsError()
{
var com = new Mock<IComService>();
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.ThrowsAsync(new InvalidOperationException("font resolver missing"));
var svc = Build(com: com, pdf: pdf);
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
Assert.False(r.Sent);
Assert.Null(r.AttachmentName);
Assert.Contains("PDF-Anhang", r.Message);
Assert.Contains("font resolver missing", r.Message);
Assert.Equal("dest@example.test", r.RequestedRecipient);
com.Verify(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task SendTestEmailAsync_MailerRejectsWithAttachment_ReportsAttachmentNotConfirmed()
{
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
.ReturnsAsync(false); // ADR 0028: also the outcome when attachmentsProcessed ≠ sent
var svc = Build(com: com);
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
Assert.False(r.Sent);
Assert.Equal(SystemStatusService.TestPdfAttachmentName, r.AttachmentName);
Assert.Contains("Anhang", r.Message);
}
[Fact]
public async Task SendTestEmailAsync_RealLetterRenderer_ProducesValidOnePagePdfAttachment()
{
// Real MigraDoc/PdfSharp rendering of the letter the service builds (raw, without the
// Spire PDF/A pass, so the test does not depend on a Spire license) — proves the text
// blocks the service assembles are renderable, not just that a stub was called.
Dictionary<string, byte[]>? capturedAttachments = null;
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
(_, _, _, _, _, att, _) => capturedAttachments = att)
.ReturnsAsync(true);
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.Returns<FuchsPdf.FdsTextBlocks, bool>((tb, draft) => FuchsPdf.WriteLetter(tb, draft, FuchsPdf.DeCulture));
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>()))
.Returns<MigraDoc.DocumentObjectModel.Document>(FuchsPdf.DocToPdfBytesRaw);
var svc = Build(com: com, pdf: pdf);
var r = await svc.SendTestEmailAsync("dest@example.test", "Fuchs Intranet – Test-E-Mail", "Dies ist eine Test-E-Mail.\nZweite Zeile.");
Assert.True(r.Sent);
byte[] bytes = Assert.Single(capturedAttachments!).Value;
Assert.True(bytes.Length > 1000, "attachment should be a real rendered PDF");
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(bytes, 0, 4));
using var opened = PdfSharp.Pdf.IO.PdfReader.Open(new System.IO.MemoryStream(bytes), PdfSharp.Pdf.IO.PdfDocumentOpenMode.Import);
Assert.Equal(1, opened.PageCount);
}
[Fact]
public async Task SendTestEmailAsync_MissingRequiredFields_DoesNotRenderPdf()
{
var pdf = new Mock<IPdfService>(MockBehavior.Strict);
var svc = Build(pdf: pdf);
var r = await svc.SendTestEmailAsync("", "Betreff", "Body");
Assert.False(r.Sent);
Assert.Null(r.AttachmentName);
}
[Fact] [Fact]
public async Task SendTestEmailAsync_OverrideActive_ReportsOverrideRecipient() public async Task SendTestEmailAsync_OverrideActive_ReportsOverrideRecipient()
{ {
+24 -3
View File
@@ -69,13 +69,34 @@ public partial class IntranetController
{ {
if (!HasForm("to", "subject")) if (!HasForm("to", "subject"))
return BadRequest400(); return BadRequest400();
// attachpdf is the dialog's Ja/Nein select ("true"/"false"); absent → attach (default).
bool attachPdf = !bool.TryParse(Form("attachpdf"), out bool parsed) || parsed;
var result = await status.SendTestEmailAsync( var result = await status.SendTestEmailAsync(
Form("to"), Form("subject"), Form("body"), HttpContext.RequestAborted); Form("to"), Form("subject"), Form("body"), attachPdf, HttpContext.RequestAborted);
_logger.LogInformation("Admin test email requested by user={User} to={To} sent={Sent}", _logger.LogInformation("Admin test email requested by user={User} to={To} attachment={Attachment} sent={Sent}",
UserAccountID, result.RequestedRecipient, result.Sent); UserAccountID, result.RequestedRecipient, result.AttachmentName, result.Sent);
return AdminJson(new { result }); return AdminJson(new { result });
} }
// Maintenance action, reachable only from the admin-gated "Specialties" page
// (IntranetController.Specialties.cs / fis.specialties.js): backs up an invoice's
// stored PDF and clears it so the document regenerates on next read (ADR 0025 /
// Docs/Decisions/0027). GET-safe crawlers/prefetchers must never trigger this, so it
// requires POST despite the rest of the Admin module tolerating either verb.
case "backupclearinvoicefile":
{
if (!HttpContext.Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase))
return BadRequest400();
string invoiceId = string.IsNullOrWhiteSpace(code) ? Form("id") : code;
if (string.IsNullOrWhiteSpace(invoiceId))
return BadRequest400();
var backup = await _invoiceFileBackup.BackupAndClearAsync(invoiceId, UserAccountID, DbSec, HttpContext.RequestAborted);
_logger.LogWarning(
"Admin invoice-file backup/clear requested by user={User} invoiceId={InvoiceId} success={Success} reason={Reason}",
UserAccountID, invoiceId, backup.Success, backup.Reason);
return AdminJson(new { backup });
}
default: default:
_logger.LogWarning("Admin: no handler for action={Action}, user={User}", id, UserAccountID); _logger.LogWarning("Admin: no handler for action={Action}, user={User}", id, UserAccountID);
return BadRequest400(); return BadRequest400();
+10 -10
View File
@@ -55,22 +55,22 @@ public partial class IntranetController
{ {
importFailed = true; importFailed = true;
importFailure = exc.Message; importFailure = exc.Message;
_logger.LogError(exc, // EventId.Name keeps the exact CodeReference the DB log used before the
"Banking upload SQL exception — file={File} destTable={DestTable} user={User}", // generic SQL sink existed (ADR 0024); {Data} carries uid/tmptbl into it.
fle.FileName, dtwa.DestinationTableName, UserAccountID); _logger.LogError(new EventId(0, "IntranetController.bam.up - sql exception"), exc,
_intranet.debug_log("IntranetController.bam.up - sql exception", "Banking upload SQL exception — file={File} destTable={DestTable} user={User} data={Data}",
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl }); fle.FileName, dtwa.DestinationTableName, UserAccountID,
new { uid = dtwa.InstanceGUID, tmptbl });
}; };
dtwa.OnCommandAfterError += (_, exc) => dtwa.OnCommandAfterError += (_, exc) =>
{ {
importFailed = true; importFailed = true;
importFailure = exc.Message; importFailure = exc.Message;
_logger.LogError(exc, _logger.LogError(new EventId(0, "IntranetController.bam.up - command-after exception"), exc,
"Banking upload merge-command exception — file={File} destTable={DestTable} " + "Banking upload merge-command exception — file={File} destTable={DestTable} " +
"rescueTable={RescueTable} user={User}", "rescueTable={RescueTable} user={User} data={Data}",
fle.FileName, dtwa.DestinationTableName, tmptbl, UserAccountID); fle.FileName, dtwa.DestinationTableName, tmptbl, UserAccountID,
_intranet.debug_log("IntranetController.bam.up - command-after exception", new { uid = dtwa.InstanceGUID, tmptbl });
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
}; };
_logger.LogDebug("Banking upload parsed {Rows} rows → temp table submit (user={User})", _logger.LogDebug("Banking upload parsed {Rows} rows → temp table submit (user={User})",
tbl.Rows.Count, UserAccountID); tbl.Rows.Count, UserAccountID);
@@ -3,6 +3,7 @@ using Fuchs.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using static OCORE.SQL.sql;
using static OCORE.web.mvc_helper_async; using static OCORE.web.mvc_helper_async;
namespace Fuchs.Controllers; namespace Fuchs.Controllers;
@@ -14,6 +15,20 @@ namespace Fuchs.Controllers;
// only signals (ADR 0007). // only signals (ADR 0007).
public partial class IntranetController public partial class IntranetController
{ {
/// <summary>
/// The date the calling user may backdate an invoice to (last working day of the previous month), or
/// null when not allowed — fds_inv &gt; 2 and no invoice finalized after that date yet. The SQL function
/// is the single source; fds__setInvoiceFinal re-checks it when @backdate = 1 (ADR 0032).
/// </summary>
private async Task<DateOnly?> GetInvoiceBackdateAsync(string fn, string id, string code)
{
var val = await getSQLValue_async<string>(
"SELECT CONVERT(varchar(10), [dbo].[fds__fn_invoiceBackdate](@authuser), 23);",
_intranet.Intranet__SQLConnectionString, "",
StdParamlist(), Security: DbSec, options: SqlOpt(fn, id, code));
return InvoiceDates.ParseBackdate(val.Result);
}
/// <summary>Standard 410 when a session token is unknown/expired — the client re-opens the draft.</summary> /// <summary>Standard 410 when a session token is unknown/expired — the client re-opens the draft.</summary>
private IActionResult DraftGone() => StatusCode(410, new { error = "expired" }); private IActionResult DraftGone() => StatusCode(410, new { error = "expired" });
@@ -71,14 +86,20 @@ public partial class IntranetController
return await JSONAsync(new { ok = true, version = session.Version }); return await JSONAsync(new { ok = true, version = session.Version });
} }
// POST inv/dpreview — { token } → { img[], total } (rendered straight from the cache) // POST inv/dpreview — { token } → { img[], total, backdate } (rendered straight from the cache).
// backdate ("yyyy-MM-dd" or null) drives the admin-only "Rechnungsdatum Vormonat" checkbox (ADR 0032).
private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code) private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code)
{ {
if (!HasForm("token")) return BadRequest400(); if (!HasForm("token")) return BadRequest400();
var doc = await _invoiceDrafts.RenderPreviewAsync(Form("token")); string token = Form("token");
if (doc == null) return DraftGone(); return await PdfPreviewAsync("inv/dpreview", token, async () =>
var imgcol = await _pdf.DocToImageCollectionAsync(doc); {
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages }); var doc = await _invoiceDrafts.RenderPreviewAsync(token);
if (doc == null) return DraftGone();
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
DateOnly? backdate = await GetInvoiceBackdateAsync(fn, id, code);
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages, backdate = backdate?.ToString("yyyy-MM-dd") });
});
} }
// POST inv/dsave — { token } → { ok, invid }; flush cache→DB + business event + draftReady // POST inv/dsave — { token } → { ok, invid }; flush cache→DB + business event + draftReady
@@ -86,16 +107,15 @@ public partial class IntranetController
{ {
if (!HasForm("token")) return BadRequest400(); if (!HasForm("token")) return BadRequest400();
string token = Form("token"); string token = Form("token");
var before = _invoiceDrafts.Get(token); // Create vs update is decided inside the flush (flushes of one session are serialised), not
if (before == null) return DraftGone(); // from the session state seen here: a save overlapping the first registration is an update.
bool existed = !string.IsNullOrEmpty(before.InvId); var flush = await _invoiceDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
if (flush == null) return DraftGone();
var fdInv = await _invoiceDrafts.FlushToDbAsync(token, UserAccountID, DbSec); var fdInv = flush.Data;
if (fdInv == null) return DraftGone();
if (string.IsNullOrEmpty(fdInv.Id)) if (string.IsNullOrEmpty(fdInv.Id))
return await InvoiceIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden."); return await InvoiceIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
await _events.InvoiceDraftRegisteredAsync(fdInv, existed, UserAccountID); await _events.InvoiceDraftRegisteredAsync(fdInv, flush.Updated, UserAccountID);
var after = _invoiceDrafts.Get(token); var after = _invoiceDrafts.Get(token);
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version); if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
return await JSONAsync(new { ok = true, invid = fdInv.Id }); return await JSONAsync(new { ok = true, invid = fdInv.Id });
@@ -131,6 +131,7 @@ public partial class IntranetController
return await HandleDatev(fn, id, code); return await HandleDatev(fn, id, code);
case "rdoc": case "rdoc":
if (!HasForm("id")) { _logger.LogWarning("rdoc: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogDebug("rdoc: report document get id={DocId}, user={User}", Form("id"), UserAccountID); _logger.LogDebug("rdoc: report document get id={DocId}, user={User}", Form("id"), UserAccountID);
return await HandleReportDoc(fn, id, code, Form("id")); return await HandleReportDoc(fn, id, code, Form("id"));
@@ -16,10 +16,22 @@ namespace Fuchs.Controllers;
// Partial class: Invoice sub-handlers // Partial class: Invoice sub-handlers
public partial class IntranetController public partial class IntranetController
{ {
/// <summary>True when <paramref name="raw"/> is an mfr invoice id (positive integer); Intranet-created
/// invoices use alphanumeric ids and have no mfr entity to reset.</summary>
internal static bool TryGetMfrInvoiceId(string? raw, out long mfrId)
=> long.TryParse(raw?.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out mfrId) && mfrId > 0;
private async Task<IActionResult> HandleInvoicePget(string fn, string id, string code) private async Task<IActionResult> HandleInvoicePget(string fn, string id, string code)
{ {
if (!HasForm("id")) { _logger.LogWarning("HandleInvoicePget: missing 'id' form field user={User}", UserAccountID); return BadRequest400(); } if (!HasForm("id")) { _logger.LogWarning("HandleInvoicePget: missing 'id' form field user={User}", UserAccountID); return BadRequest400(); }
if (!long.TryParse(Form("id"), out long tgtid)) { _logger.LogWarning("HandleInvoicePget: invalid 'id' value='{Value}' user={User}", Form("id"), UserAccountID); return BadRequest400(); } if (!TryGetMfrInvoiceId(Form("id"), out long tgtid))
{
// Intranet-created invoices carry an alphanumeric Id (fds__fn_invoice_id) and have no mfr
// entity to reset. Both storno paths call pget first, so rejecting them here (400) left the
// storno window empty. Legacy parity: skip the mfr reset and report success.
_logger.LogDebug("HandleInvoicePget: non-mfr invoice id='{Value}', nothing to reset user={User}", Form("id"), UserAccountID);
return await JSONAsync(new { ok = true });
}
_logger.LogDebug("HandleInvoicePget tgtid={TgtId} user={User}", tgtid, UserAccountID); _logger.LogDebug("HandleInvoicePget tgtid={TgtId} user={User}", tgtid, UserAccountID);
using (var mfr = _mfrFactory.Create()) using (var mfr = _mfrFactory.Create())
@@ -263,6 +275,16 @@ public partial class IntranetController
return await JSONAsync(new { payments = sqldt.Tables("items").toArrayofObjectDictionaries() }); return await JSONAsync(new { payments = sqldt.Tables("items").toArrayofObjectDictionaries() });
} }
/// <summary>
/// DATEV overview (<c>inv/datev</c>). Parameters are bound by name: the proc's signature is
/// (@tgtdate, @mode, @files, @authuser) and the former positional call put @authuser into
/// @files and never supplied @authuser, so every call failed. @files = 0 — the overview needs
/// no PDF bytes. Result sets in proc order: header, invoice documents, bookings, debtors.
/// </summary>
internal const string DatevOverviewSql =
"EXECUTE [dbo].[fds__getDatevExports] @tgtdate = @tgtdate, @mode = @mode, @files = 0, @authuser = @authuser;";
internal static readonly string[] DatevOverviewTables = { "admin", "files", "invoices", "debits" };
private async Task<IActionResult> HandleDatev(string fn, string id, string code) private async Task<IActionResult> HandleDatev(string fn, string id, string code)
{ {
if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd", if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
@@ -274,10 +296,10 @@ public partial class IntranetController
string mode = Form("mode").ne("m"); string mode = Form("mode").ne("m");
_logger.LogDebug("HandleDatev tgtdate={TgtDate} mode={Mode} user={User}", tgtdate.ToString("yy-MM-dd"), mode, UserAccountID); _logger.LogDebug("HandleDatev tgtdate={TgtDate} mode={Mode} user={User}", tgtdate.ToString("yy-MM-dd"), mode, UserAccountID);
var dset = await getSQLDataSet_async( var dset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getDatevExports] @tgtdate, @mode, @authuser;", DatevOverviewSql,
_intranet.Intranet__SQLConnectionString, _intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_Date("@tgtdate", tgtdate), SQL_VarChar("@mode", mode)), StdParamlist(SQL_Date("@tgtdate", tgtdate), SQL_VarChar("@mode", mode)),
tablenames: new[] { "files", "invoices", "debits" }, tablenames: DatevOverviewTables,
Security: DbSec, options: SqlOpt(fn, id, code)); Security: DbSec, options: SqlOpt(fn, id, code));
_logger.LogDebug("HandleDatev tgtdate={TgtDate} files={FileCount} invoices={InvCount} user={User}", _logger.LogDebug("HandleDatev tgtdate={TgtDate} files={FileCount} invoices={InvCount} user={User}",
tgtdate.ToString("yy-MM-dd"), dset.Tables("files").Rows.Count, dset.Tables("invoices").Rows.Count, UserAccountID); tgtdate.ToString("yy-MM-dd"), dset.Tables("files").Rows.Count, dset.Tables("invoices").Rows.Count, UserAccountID);
@@ -293,18 +315,49 @@ public partial class IntranetController
_logger.LogDebug("HandleReportDoc reportid={ReportId} typ={Typ} user={User}", reportid, Form("typ"), UserAccountID); _logger.LogDebug("HandleReportDoc reportid={ReportId} typ={Typ} user={User}", reportid, Form("typ"), UserAccountID);
byte[]? content = null; byte[]? content = null;
var file = _mfr.GetReportDoc(ref content, reportid); var file = _mfr.GetReportDoc(ref content, reportid);
if (file == null) string fileName, mimeType;
if (file != null && content is { Length: > 0 })
{ {
_logger.LogWarning("HandleReportDoc: document not found reportid={ReportId} user={User}", reportid, UserAccountID); fileName = file.Name;
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" }); mimeType = file.MimeType();
}
else
{
// Regenerate-on-miss (ADR 0025). Finalised, non-external only: fds__setInvoiceFile
// writes WHERE [file] IS NULL, so persisting a draft render here would permanently
// block the real final PDF at finalisation.
var fdInv = await _invoices.LoadInvoiceAsync(reportid, UserAccountID, DbSec);
bool isExternal = fdInv.InvoiceRegistration?.getItem("IsExternal", false) is true;
if (!string.IsNullOrEmpty(fdInv.Id) && !fdInv.IsDraft && !isExternal)
{
_logger.LogWarning("HandleReportDoc: no stored document for reportid={ReportId}, regenerating, user={User}", reportid, UserAccountID);
content = await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec);
}
if (content == null || content.Length == 0)
{
_logger.LogWarning("HandleReportDoc: document not found reportid={ReportId} user={User}", reportid, UserAccountID);
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
}
fileName = SafeDocumentFileName(
fdInv.InvoiceRegistration?.getString("DocumentName") ?? "", $"Rechnung_{fdInv.Id.ne(reportid)}.pdf");
mimeType = "application/pdf";
} }
_logger.LogDebug("HandleReportDoc found reportid={ReportId} fileName={FileName} mimeType={MimeType} size={Size} user={User}", _logger.LogDebug("HandleReportDoc found reportid={ReportId} fileName={FileName} mimeType={MimeType} size={Size} user={User}",
reportid, file.Name, file.MimeType(), content?.Length ?? 0, UserAccountID); reportid, fileName, mimeType, content.Length, UserAccountID);
return Form("typ") != "img" if (Form("typ") != "img")
? await FileContentResultAsync(content!, file.MimeType(), file.Name) return await FileContentResultAsync(content, mimeType, fileName);
: await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) }); return await PdfPreviewAsync("inv/rdoc", reportid, async () =>
await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) }));
} }
/// <summary>
/// Download filename for a regenerated document — the one shared sanitiser
/// (<see cref="fds.FdsDocumentNames.SafeFileName"/>) that <c>FdsMfr.GetReportDoc</c> also
/// applies to stored documents, so both paths name the same DocumentName identically.
/// </summary>
internal static string SafeDocumentFileName(string documentName, string fallback) =>
fds.FdsDocumentNames.SafeFileName(documentName, fallback);
private async Task<IActionResult> HandleReportDocByName(string fn, string id, string code) private async Task<IActionResult> HandleReportDocByName(string fn, string id, string code)
{ {
if (!HasForm("name")) { _logger.LogWarning("HandleReportDocByName: missing 'name' form field user={User}", UserAccountID); return BadRequest400(); } if (!HasForm("name")) { _logger.LogWarning("HandleReportDocByName: missing 'name' form field user={User}", UserAccountID); return BadRequest400(); }
@@ -422,7 +475,9 @@ public partial class IntranetController
{ {
["Id"] = d["Id"], ["Id"] = d["Id"],
["net_val"] = net, ["net_val"] = net,
["vat_val"] = net * vat * 0.01, // Cent-rounded in decimal like InvoiceDraftCalculator; the former double product
// (e.g. -615.3757999999999) carried float noise into the draft's VAT sums.
["vat_val"] = Math.Round((decimal)net * (decimal)vat / 100m, 2, MidpointRounding.AwayFromZero),
["vat"] = vat == 0 ? "" : vat.ToString("0.00", FuchsPdf.DeCulture) + "%", ["vat"] = vat == 0 ? "" : vat.ToString("0.00", FuchsPdf.DeCulture) + "%",
["svcnet_val"] = d.no("value_service", 0), ["svcnet_val"] = d.no("value_service", 0),
["net"] = d.no("value", 0), ["net"] = d.no("value", 0),
@@ -434,7 +489,10 @@ public partial class IntranetController
["NameOrNumber"] = "", ["NameOrNumber"] = "",
["htmltext"] = d["Text"], ["htmltext"] = d["Text"],
["position"] = d["Position"], ["position"] = d["Position"],
["SortOrder"] = d["SortOrder"] ["SortOrder"] = d["SortOrder"],
// Round-trips the referenced invoice (CanceledInvoice / IntInvoiceAllocation) so a
// re-saved draft keeps it — fds__setInvoiceFinal needs it for Replaces/allocations.
["reference_InvId"] = d.nz("reference_InvId") is { Length: > 0 } refId ? refId : null
}); });
} }
sdic["items"] = itm; sdic["items"] = itm;
@@ -0,0 +1,125 @@
using System.Diagnostics;
using Fuchs.Observability;
using Microsoft.AspNetCore.Mvc;
namespace Fuchs.Controllers;
// Partial class: shared guard for every "PDF-Vorschau" endpoint (inv/dpreview, rem/dpreview,
// req/idoc+img, rem/idoc+img, inv/rdoc+img, rem/rdoc+img).
//
// A preview render can fail deep inside MigraDoc / Spire (fonts, licence, malformed HTML in an
// item text, an oversized inline image, ...). Before this guard such a failure only surfaced as
// the generic catch-all in Do(): an ErrorLog.txt line on the web server, nothing in the DB log
// support actually reads, and an empty "Der Server hat einen Fehler gemeldet:" alert in the
// browser. Every preview now goes through PdfPreviewAsync so the failure is (1) logged with
// what/ref/user/duration — which the SQL log sink (ADR 0024) additionally persists to
// fds__debuglog — (2) counted, (3) toasted to the user and (4) returned with a readable
// description header.
public partial class IntranetController
{
internal const string PdfPreviewLogReference = "PDF preview failed";
/// <summary>
/// Runs <paramref name="render"/> (the endpoint body that produces the preview response) and
/// turns any exception into a fully logged, user-readable 500.
/// </summary>
/// <param name="what">Endpoint tag, e.g. <c>inv/dpreview</c>.</param>
/// <param name="refId">Draft token / invoice / reminder / report id the preview is for.</param>
private async Task<IActionResult> PdfPreviewAsync(string what, string refId, Func<Task<IActionResult>> render)
{
var sw = Stopwatch.StartNew();
using var act = FuchsTelemetry.StartActivity("pdf.preview");
act?.SetTag("fuchs.pdf.preview.what", what);
act?.SetTag("fuchs.pdf.preview.ref", refId);
_logger.LogDebug("PDF preview {What} ref={RefId} user={User} started", what, refId, UserAccountID);
try
{
var result = await render();
sw.Stop();
// The client (postXT) gives up after a fixed timeout and aborts the connection while a
// slow render/regenerate-on-miss (ADR 0025) is still running server-side; that work is
// deliberately NOT cancelled — the regenerated document must still be stored so the
// next click is fast (ADR 0025) — but writing the now-finished result back to a socket
// the client already closed would only surface as a low-level framework/Kestrel
// exception that never reaches an EventId'd `_logger` call: it is either below the SQL
// sink's Warning floor or purely a debugger first-chance notification that never goes
// through ILogger at all — exactly the "exception in the debug console but nothing in
// fds__debuglog" gap. Recognise the disconnect here instead and log it deliberately, at
// Warning, so it is guaranteed to land in fds__debuglog (ADR 0024) — and skip the write.
if (HttpContext.RequestAborted.IsCancellationRequested)
{
FuchsTelemetry.PdfPreviews.Add(1,
new KeyValuePair<string, object?>("what", what),
new KeyValuePair<string, object?>("outcome", "client-disconnected"));
_logger.LogWarning(
"PDF preview {What} ref={RefId} user={User} finished in {Ms} ms but the client had already disconnected (timeout) — result discarded",
what, refId, UserAccountID, sw.ElapsedMilliseconds);
return new EmptyResult();
}
// A draft session that expired between page load and preview click is an expected
// client-driven outcome (the browser reopens the draft), not a render failure — keep
// it out of the failure telemetry/logging this guard exists to produce.
var outcome = result is ObjectResult { StatusCode: 410 } ? "gone" : "success";
FuchsTelemetry.PdfPreviews.Add(1,
new KeyValuePair<string, object?>("what", what),
new KeyValuePair<string, object?>("outcome", outcome));
_logger.LogInformation("PDF preview {What} ref={RefId} user={User} {Outcome} in {Ms} ms",
what, refId, UserAccountID, outcome, sw.ElapsedMilliseconds);
return result;
}
catch (Exception ex)
{
sw.Stop();
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
// Same client-gone rationale as above, but the render itself threw (e.g. it tried to
// write partial output to the aborted connection). Still an expected outcome of a
// client timeout, not a render defect — logged at Warning, not Error, but still
// guaranteed to reach fds__debuglog (ADR 0024) rather than only a debug console.
if (HttpContext.RequestAborted.IsCancellationRequested)
{
FuchsTelemetry.PdfPreviews.Add(1,
new KeyValuePair<string, object?>("what", what),
new KeyValuePair<string, object?>("outcome", "client-disconnected"));
_logger.LogWarning(ex,
"PDF preview {What} ref={RefId} user={User} failed after {Ms} ms because the client had already disconnected",
what, refId, UserAccountID, sw.ElapsedMilliseconds);
return new EmptyResult();
}
FuchsTelemetry.PdfPreviews.Add(1,
new KeyValuePair<string, object?>("what", what),
new KeyValuePair<string, object?>("outcome", "failure"));
// EventId.Name keeps the exact "PDF preview failed (what)" CodeReference the DB log
// used before the generic SQL sink existed (ADR 0024).
_logger.LogError(new EventId(0, $"{PdfPreviewLogReference} ({what})"), ex,
"PDF preview {What} ref={RefId} user={User} failed after {Ms} ms",
what, refId, UserAccountID, sw.ElapsedMilliseconds);
// Pre-auth flows (some preview endpoints are in _allowedGet, see IntranetController.cs)
// have no user session to notify, matching the guard the Do() catch-all uses.
if (UserIdent.IsAuthenticated)
{
await _events.UserIssueAsync(
"PDF-Vorschau fehlgeschlagen",
"Die PDF-Vorschau konnte nicht erstellt werden. Der Fehler wurde protokolliert.",
UserAccountID,
new Dictionary<string, object?> { ["what"] = what, ["ref"] = refId });
}
return ServerError(PdfPreviewFailureMessage(ex, UserIdent.IsAuthenticated));
}
}
/// <summary>
/// User-facing message. The innermost exception text is only included for an authenticated
/// intranet user — some preview endpoints are reachable unauthenticated (see _allowedGet in
/// IntranetController.cs), so an anonymous caller gets a generic message instead.
/// </summary>
internal static string PdfPreviewFailureMessage(Exception ex, bool includeDetail)
{
if (!includeDetail) return "Die PDF-Vorschau konnte nicht erstellt werden.";
string detail = ex.GetBaseException().Message?.Trim() ?? "";
return detail.Length == 0
? "Die PDF-Vorschau konnte nicht erstellt werden."
: $"Die PDF-Vorschau konnte nicht erstellt werden: {detail}";
}
}
@@ -89,9 +89,11 @@ public partial class IntranetController
_logger.LogWarning("Reminder rdoc: document not found for reminder {RemId} user={User}", Form("id"), UserAccountID); _logger.LogWarning("Reminder rdoc: document not found for reminder {RemId} user={User}", Form("id"), UserAccountID);
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" }); return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
} }
return Form("typ") != "img" if (Form("typ") != "img")
? await FileContentResultAsync(fc, file.MimeType(), file.Name) return await FileContentResultAsync(fc, file.MimeType(), file.Name);
: await JSONAsync(new { id = Form("id"), img = await BuildPdfImageArray(fc) }); string remId = Form("id");
return await PdfPreviewAsync("rem/rdoc", remId, async () =>
await JSONAsync(new { id = remId, img = await BuildPdfImageArray(fc) }));
} }
case "idoc": return await HandleReminderIdoc(fn, id, code); case "idoc": return await HandleReminderIdoc(fn, id, code);
@@ -153,8 +155,11 @@ public partial class IntranetController
{ {
var remdoc = new Dictionary<string, byte[]> var remdoc = new Dictionary<string, byte[]>
{ [frdic.nz("DocumentName", "").ne("Zahlungserinnerung.pdf")] = filebyte }; { [frdic.nz("DocumentName", "").ne("Zahlungserinnerung.pdf")] = filebyte };
// The invoice copy is optional: an empty varbinary (invoice file never stored) must
// not attach a 0-byte file — the mailer refuses empty attachments and the whole
// reminder would fail instead of going out without the copy.
if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) && if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) &&
frdic.no("InvoiceFile", null!) is byte[] invFile) frdic.no("InvoiceFile", null!) is byte[] { Length: > 0 } invFile)
remdoc[frdic.nz("InvoiceFileName")] = invFile; remdoc[frdic.nz("InvoiceFileName")] = invFile;
_logger.LogInformation("Reminder conf: emailing finalized reminder {RemId} to {Email} user={User}", _logger.LogInformation("Reminder conf: emailing finalized reminder {RemId} to {Email} user={User}",
@@ -206,13 +211,23 @@ public partial class IntranetController
string filename = fdRem.ReminderRegistration!.nz("DocumentName").ne($"Zahlungserinnerung_{fdRem.Id}.pdf"); string filename = fdRem.ReminderRegistration!.nz("DocumentName").ne($"Zahlungserinnerung_{fdRem.Id}.pdf");
if (Form("typ") != "img") if (Form("typ") != "img")
{ {
byte[] ct = Form("create", "0") != "1" byte[]? ct = Form("create", "0") != "1"
? (await _reminders.GetReminderFileAsync(fdRem, fdRem.IsDraft, _mfr, UserAccountID, DbSec)) is { Length: > 0 } f1 ? f1 : await _reminders.StoreReminderDocumentFileAsync(fdRem, fdRem.IsDraft, UserAccountID, DbSec) ? (await _reminders.GetReminderFileAsync(fdRem, fdRem.IsDraft, _mfr, UserAccountID, DbSec)) is { Length: > 0 } f1 ? f1 : await _reminders.StoreReminderDocumentFileAsync(fdRem, fdRem.IsDraft, UserAccountID, DbSec)
: _pdf.DocToPdfBytes(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft)); : _pdf.DocToPdfBytes(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
// StoreReminderDocumentFileAsync signals failure with Array.Empty<byte>() — without this
// guard a failed render/store would be served as a 0-byte "PDF".
if (ct is not { Length: > 0 })
{
_logger.LogError("HandleReminderIdoc: no PDF bytes available for reminder {RemId} user={User}", fdRem.Id, UserAccountID);
return await ReminderIssueResult("Die Mahnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdRem.Id);
}
return await FileContentResultAsync(ct, "application/pdf", filename, inline: true); return await FileContentResultAsync(ct, "application/pdf", filename, inline: true);
} }
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft)); return await PdfPreviewAsync("rem/idoc", fdRem.Id, async () =>
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages }); {
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
});
} }
private async Task<IActionResult> HandleReminderResend(string fn, string id, string code) private async Task<IActionResult> HandleReminderResend(string fn, string id, string code)
@@ -235,8 +250,11 @@ public partial class IntranetController
{ {
var remdoc = new Dictionary<string, byte[]> var remdoc = new Dictionary<string, byte[]>
{ [frdic.nz("DocumentName", "").ne("Zahlungserinnerung.pdf")] = filebyte }; { [frdic.nz("DocumentName", "").ne("Zahlungserinnerung.pdf")] = filebyte };
// The invoice copy is optional: an empty varbinary (invoice file never stored) must
// not attach a 0-byte file — the mailer refuses empty attachments and the whole
// reminder would fail instead of going out without the copy.
if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) && if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) &&
frdic.no("InvoiceFile", null!) is byte[] invFile) frdic.no("InvoiceFile", null!) is byte[] { Length: > 0 } invFile)
remdoc[frdic.nz("InvoiceFileName")] = invFile; remdoc[frdic.nz("InvoiceFileName")] = invFile;
bool sent = await _comService.SendEmailAsync($"rem_{remId}", bool sent = await _comService.SendEmailAsync($"rem_{remId}",
$"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}", $"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}",
@@ -67,10 +67,14 @@ public partial class IntranetController
private async Task<IActionResult> HandleReminderDraftPreview(string fn, string id, string code) private async Task<IActionResult> HandleReminderDraftPreview(string fn, string id, string code)
{ {
if (!HasForm("token")) return BadRequest400(); if (!HasForm("token")) return BadRequest400();
var doc = _reminderDrafts.RenderPreview(Form("token")); string token = Form("token");
if (doc == null) return DraftGone(); return await PdfPreviewAsync("rem/dpreview", token, async () =>
var imgcol = await _pdf.DocToImageCollectionAsync(doc); {
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages }); var doc = _reminderDrafts.RenderPreview(token);
if (doc == null) return DraftGone();
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
});
} }
// POST rem/dsave — { token } → { ok, remid }; flush cache→DB + business event + draftReady // POST rem/dsave — { token } → { ok, remid }; flush cache→DB + business event + draftReady
@@ -78,16 +82,14 @@ public partial class IntranetController
{ {
if (!HasForm("token")) return BadRequest400(); if (!HasForm("token")) return BadRequest400();
string token = Form("token"); string token = Form("token");
var before = _reminderDrafts.Get(token); // Create vs update is decided inside the flush (serialised per session) — see HandleDraftSave.
if (before == null) return DraftGone(); var flush = await _reminderDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
bool existed = !string.IsNullOrEmpty(before.RemId); if (flush == null) return DraftGone();
var fdRem = flush.Data;
var fdRem = await _reminderDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
if (fdRem == null) return DraftGone();
if (string.IsNullOrEmpty(fdRem.Id)) if (string.IsNullOrEmpty(fdRem.Id))
return await ReminderIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden."); return await ReminderIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
await _events.ReminderDraftRegisteredAsync(fdRem, existed, UserAccountID); await _events.ReminderDraftRegisteredAsync(fdRem, flush.Updated, UserAccountID);
var after = _reminderDrafts.Get(token); var after = _reminderDrafts.Get(token);
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version); if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
return await JSONAsync(new { ok = true, remid = fdRem.Id }); return await JSONAsync(new { ok = true, remid = fdRem.Id });
@@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using Fuchs.intranet; using Fuchs.intranet;
using Fuchs.Observability;
using Fuchs.Services; using Fuchs.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
@@ -302,14 +303,41 @@ public partial class IntranetController
private async Task<IActionResult> HandleRequestSconf(string fn, string id, string code) private async Task<IActionResult> HandleRequestSconf(string fn, string id, string code)
{ {
if (!HasForm("id")) { _logger.LogWarning("HandleRequestSconf: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); } if (!HasForm("id")) { _logger.LogWarning("HandleRequestSconf: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogInformation("HandleRequestSconf: finalizing invoice {InvId} user={User}", Form("id"), UserAccountID); // Admin option: date the invoice to the last working day of the previous month (ADR 0032).
// fds__setInvoiceFinal re-validates (fds_inv > 2, no later invoice) and throws otherwise.
bool backdate = InvoiceDates.IsBackdateRequested(Form("backdate"));
_logger.LogInformation("HandleRequestSconf: finalizing invoice {InvId} backdate={Backdate} user={User}", Form("id"), backdate, UserAccountID);
var dt = await getSQLDatatable_async( var dt = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser;", "EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser, @backdate;",
_intranet.Intranet__SQLConnectionString, _intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@Id", Form("id"))), StdParamlist(SQL_VarChar("@Id", Form("id")), SQL_Bit("@backdate", backdate)),
Security: DbSec, options: SqlOpt(fn, id, code)); Security: DbSec, options: SqlOpt(fn, id, code));
if (!string.IsNullOrEmpty(dt.Exception)) if (!string.IsNullOrEmpty(dt.Exception))
_logger.LogError("HandleRequestSconf: SQL error finalizing invoice {InvId}: {SqlError}, user={User}", Form("id"), dt.Exception, UserAccountID); _logger.LogError("HandleRequestSconf: SQL error {SqlErrorNumber} finalizing invoice {InvId}: {SqlError}, user={User}", dt.ExceptionCode, Form("id"), dt.Exception, UserAccountID);
// Only SQL error 60001 is a backdate rejection. A 0-row result means the invoice is already
// final/sent (double submit) — it must not be reported as "create it again without backdating".
var outcome = InvoiceDates.ClassifyFinalizeResult(dt.Exception, dt.ExceptionCode, dt.Count);
if (backdate)
{
FuchsTelemetry.InvoicesBackdated.Add(1, new KeyValuePair<string, object?>("outcome", InvoiceDates.OutcomeTag(outcome)));
if (outcome == InvoiceFinalizeOutcome.Finalized)
_logger.LogInformation(new EventId(0, "InvoiceBackdated"),
"Invoice {InvId} finalized with backdated invoice date {DateFinalized} user={User}",
Form("id"), dt.FirstRow.toObjectDictionary().nz("DateFinalized"), UserAccountID);
}
switch (outcome)
{
case InvoiceFinalizeOutcome.BackdateNotAllowed:
return await InvoiceIssueResult(
"Die Rechnung wurde nicht erstellt: Das Rechnungsdatum kann nicht mehr auf den Vormonat gesetzt werden (es wurde bereits eine Rechnung im neuen Monat erstellt). Bitte ohne Rückdatierung erneut erstellen.",
Form("id"));
case InvoiceFinalizeOutcome.AlreadyFinal:
return await InvoiceIssueResult(
"Die Rechnung ist bereits abgeschlossen oder versendet und wurde nicht erneut erstellt.",
Form("id"));
case InvoiceFinalizeOutcome.Error:
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.", Form("id"));
}
var frdic = dt.FirstRow.toObjectDictionary(); var frdic = dt.FirstRow.toObjectDictionary();
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true) if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
{ {
@@ -357,7 +385,7 @@ public partial class IntranetController
else if (filebyte.Length == 0) else if (filebyte.Length == 0)
{ {
_logger.LogError( _logger.LogError(
"Invoice PDF render returned 0 bytes — invoiceId={InvoiceId} user={User}", "Invoice PDF could not be rendered or stored (see preceding InvoiceService/SQL error) — invoiceId={InvoiceId} user={User}",
invId, UserAccountID); invId, UserAccountID);
await _events.InvoiceIssueAsync( await _events.InvoiceIssueAsync(
$"Die Rechnungs-PDF {frdic.nz("DocumentName").ne($"Rechnung_{invId}.pdf")} konnte nicht erstellt werden.", $"Die Rechnungs-PDF {frdic.nz("DocumentName").ne($"Rechnung_{invId}.pdf")} konnte nicht erstellt werden.",
@@ -387,7 +415,7 @@ public partial class IntranetController
private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code) private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code)
{ {
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleRequestIdoc: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); } if (!HasForm("id")) { _logger.LogWarning("HandleRequestIdoc: missing form field 'id', user={User}", UserAccountID); return StatusCode(404); }
_logger.LogDebug("HandleRequestIdoc: invoiceId={InvId} typ={Typ} create={Create} user={User}", Form("id"), Form("typ"), Form("create", "0"), UserAccountID); _logger.LogDebug("HandleRequestIdoc: invoiceId={InvId} typ={Typ} create={Create} user={User}", Form("id"), Form("typ"), Form("create", "0"), UserAccountID);
var fdInv = await _invoices.LoadInvoiceAsync(Form("id"), UserAccountID, DbSec); var fdInv = await _invoices.LoadInvoiceAsync(Form("id"), UserAccountID, DbSec);
if (string.IsNullOrEmpty(fdInv.Id)) { _logger.LogWarning("HandleRequestIdoc: invoice not found id={InvId} user={User}", Form("id"), UserAccountID); return StatusCode(404, new { error = "Rechnung wurde nicht gefunden" }); } if (string.IsNullOrEmpty(fdInv.Id)) { _logger.LogWarning("HandleRequestIdoc: invoice not found id={InvId} user={User}", Form("id"), UserAccountID); return StatusCode(404, new { error = "Rechnung wurde nicht gefunden" }); }
@@ -397,16 +425,24 @@ public partial class IntranetController
byte[]? ct = Form("create", "0") != "1" byte[]? ct = Form("create", "0") != "1"
? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec) ? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec)
: _pdf.DocToPdfBytes(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft)); : _pdf.DocToPdfBytes(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
if (ct == null) // StoreInvoiceDocumentFileAsync signals failure with Array.Empty<byte>(), not null — a
// bare `ct == null` check never catches that and would silently serve a 0-byte "PDF".
if (ct is not { Length: > 0 })
{
_logger.LogError("HandleRequestIdoc: no PDF bytes available for invoice {InvId} user={User}", fdInv.Id, UserAccountID);
return await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id); return await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id);
}
// Serve inline for the in-browser viewer, but carry the real DocumentName so the browser's // Serve inline for the in-browser viewer, but carry the real DocumentName so the browser's
// "download" uses "Rechnung R2026-0121.pdf" instead of the "idoc" endpoint segment. (The // "download" uses "Rechnung R2026-0121.pdf" instead of the "idoc" endpoint segment. (The
// OCORE FileContentResult helper drops the filename under ASP.NET Core, so set it here.) // OCORE FileContentResult helper drops the filename under ASP.NET Core, so set it here.)
SetInlinePdfFilename(filename); SetInlinePdfFilename(filename);
return File(ct, "application/pdf"); return File(ct, "application/pdf");
} }
var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft)); return await PdfPreviewAsync("req/idoc", fdInv.Id, async () =>
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages }); {
var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
});
} }
private async Task<IActionResult> HandleRequestResend(string fn, string id, string code) private async Task<IActionResult> HandleRequestResend(string fn, string id, string code)
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Mvc;
using static OCORE.web.mvc_helper_async;
namespace Fuchs.Controllers;
// Partial class: "Specialties" module — the admin page (URL /specialties, menu entry under
// Administration) that surfaces the otherwise hidden maintenance actions as small card forms.
//
// This partial only answers the module's auth probe; the cards call the real action endpoints
// directly (e.g. POST /do/admin/backupclearinvoicefile/<id> in IntranetController.Admin.cs),
// each of which enforces the same fds_sys > 4 gate server-side. See
// Docs/Concepts/admin-specialties.md.
public partial class IntranetController
{
private async Task<IActionResult> Do_Process_Specialties(string fn, string id, string code)
{
_logger.LogDebug("Do_Process_Specialties action={Action} user={User}", id, UserAccountID);
int authLevel = await GetSystemAdminAuthAsync(fn, id, code);
bool authorized = authLevel > AdminMinAuthExclusive;
if (id.Equals("auth", StringComparison.OrdinalIgnoreCase))
return await JSONAsync(new { manage = authorized ? 1 : 0, level = authLevel });
if (!authorized)
{
_logger.LogWarning("Specialties access denied for user={User} (fds_sys={Level}) action={Action}",
UserAccountID, authLevel, id);
return Unauthorized401();
}
_logger.LogWarning("Specialties: no handler for action={Action}, user={User}", id, UserAccountID);
return BadRequest400();
}
}
+33 -4
View File
@@ -33,6 +33,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
private readonly IWidgetService _widgets; private readonly IWidgetService _widgets;
private readonly IReportService _reports; private readonly IReportService _reports;
private readonly IInvoiceService _invoices; private readonly IInvoiceService _invoices;
private readonly IInvoiceFileBackupService _invoiceFileBackup;
private readonly IReminderService _reminders; private readonly IReminderService _reminders;
private readonly IEventService _events; private readonly IEventService _events;
private readonly IInvoiceDraftService _invoiceDrafts; private readonly IInvoiceDraftService _invoiceDrafts;
@@ -65,6 +66,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
IWidgetService widgets, IWidgetService widgets,
IReportService reports, IReportService reports,
IInvoiceService invoices, IInvoiceService invoices,
IInvoiceFileBackupService invoiceFileBackup,
IReminderService reminders, IReminderService reminders,
IEventService events, IEventService events,
IInvoiceDraftService invoiceDrafts, IInvoiceDraftService invoiceDrafts,
@@ -82,6 +84,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
_widgets = widgets; _widgets = widgets;
_reports = reports; _reports = reports;
_invoices = invoices; _invoices = invoices;
_invoiceFileBackup = invoiceFileBackup;
_reminders = reminders; _reminders = reminders;
_events = events; _events = events;
_invoiceDrafts = invoiceDrafts; _invoiceDrafts = invoiceDrafts;
@@ -123,7 +126,25 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
// ── Action helpers ──────────────────────────────────────────────────────── // ── Action helpers ────────────────────────────────────────────────────────
protected IActionResult Unauthorized401() => StatusCode(401); protected IActionResult Unauthorized401() => StatusCode(401);
protected IActionResult BadRequest400() => BadRequest(); protected IActionResult BadRequest400() => BadRequest();
protected IActionResult ServerError(string msg = "") => StatusCode(500, new { error = msg }); /// <summary>
/// 500 with the message both in the JSON body and in the <c>x-ocms-desc</c> header, which is
/// the only place the frontend's default failure handler (<c>$ocms.failure</c>) reads a
/// description from — without it the user only ever saw an empty "Der Server hat einen Fehler
/// gemeldet:" alert.
/// </summary>
protected IActionResult ServerError(string msg = "")
{
if (!string.IsNullOrEmpty(msg))
Response.Headers["x-ocms-desc"] = EncodeResponseDescription(msg);
return StatusCode(500, new { error = msg });
}
/// <summary>
/// Header-safe (ASCII, percent-encoded) form of a user message; the client decodes it with
/// <c>decodeURIComponent</c> after mapping <c>+</c> to a space, so spaces must be <c>%20</c>.
/// </summary>
internal static string EncodeResponseDescription(string msg) =>
Uri.EscapeDataString((msg ?? "").Replace("\r", " ").Replace("\n", " ").Trim());
// ── Index (GET /) ───────────────────────────────────────────────────────── // ── Index (GET /) ─────────────────────────────────────────────────────────
[AllowAnonymous] [AllowAnonymous]
@@ -167,6 +188,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
"rep" => await Do_Process_Reports(fn, id, code), "rep" => await Do_Process_Reports(fn, id, code),
"bam" => await Do_Process_Bankings(fn, id, code), "bam" => await Do_Process_Bankings(fn, id, code),
"admin" => await Do_Process_Admin(fn, id, code), "admin" => await Do_Process_Admin(fn, id, code),
"specialties" => await Do_Process_Specialties(fn, id, code),
"usr" => await Do_Process_AdminUsers(fn, id, code), "usr" => await Do_Process_AdminUsers(fn, id, code),
"usrgrp" => await Do_Process_AdminGroups(fn, id, code), "usrgrp" => await Do_Process_AdminGroups(fn, id, code),
"auth" => await HandleAuth(fn, id, code), "auth" => await HandleAuth(fn, id, code),
@@ -187,8 +209,11 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Unhandled exception in Do fn={Fn} id={Id} code={Code} user={User}", // EventId.Name keeps the exact CodeReference the DB log used before the generic SQL
fn, id, code, UserAccountID); // sink existed (ADR 0024); formKeys is form field NAMES only, never values (PII/tokens).
_logger.LogError(new EventId(0, $"Unhandled exception in Do {fn}/{id}"), ex,
"Unhandled exception in Do fn={Fn} id={Id} code={Code} user={User} formKeys={FormKeys}",
fn, id, code, UserAccountID, RequestFormKeys());
// Standing rule: an exception that interrupts a user-initiated action must reach the // Standing rule: an exception that interrupts a user-initiated action must reach the
// user as a notification, not only the log. This is the catch-all safety net for any // user as a notification, not only the log. This is the catch-all safety net for any
// Do_Process_* action that throws without first publishing its own (more specific) // Do_Process_* action that throws without first publishing its own (more specific)
@@ -202,10 +227,14 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
UserAccountID, UserAccountID,
new Dictionary<string, object?> { ["fn"] = fn }); new Dictionary<string, object?> { ["fn"] = fn });
} }
return ServerError(); return ServerError("Die Aktion konnte aufgrund eines unerwarteten Fehlers nicht abgeschlossen werden.");
} }
} }
/// <summary>Form field names of the current request (never values — they may carry PII/tokens).</summary>
private string[] RequestFormKeys() =>
Request.HasFormContentType ? Request.Form.Keys.ToArray() : Array.Empty<string>();
// ── Auth helper ─────────────────────────────────────────────────────────── // ── Auth helper ───────────────────────────────────────────────────────────
private async Task<IActionResult> HandleAuth(string fn, string id, string code) private async Task<IActionResult> HandleAuth(string fn, string id, string code)
{ {
+3 -2
View File
@@ -196,8 +196,8 @@ Cookie-based authentication (`CookieAuthenticationDefaults`) with custom claims
> **Status: DONE.** The services below are implemented and DI-registered in > **Status: DONE.** The services below are implemented and DI-registered in
> `Program.cs`. The original extraction rationale is retained for reference / > `Program.cs`. The original extraction rationale is retained for reference /
> history. `FuchsFdsEmail` → `IComService` (ProcessWeb Mailer API, inline > history. `FuchsFdsEmail` → `IComService` (ProcessWeb Mailer API, multipart
> base64 attachments), `FuchsWidgets` → `IWidgetService`, `FuchsPdf` → > attachments — see `Docs/Concepts/processweb-mailer-interface.md`), `FuchsWidgets` → `IWidgetService`, `FuchsPdf` →
> `IPdfService`, `Banking` → `IBankingService` (now MT940 **and** CAMT), > `IPdfService`, `Banking` → `IBankingService` (now MT940 **and** CAMT),
> `FdsInvoiceData`/`FdsReminderData` → `IInvoiceService`/`IReminderService` > `FdsInvoiceData`/`FdsReminderData` → `IInvoiceService`/`IReminderService`
> (data classes are now pure POCOs), `FuchsReports` → `IReportService` > (data classes are now pure POCOs), `FuchsReports` → `IReportService`
@@ -446,3 +446,4 @@ public class MfrClientFactory : IMfrClientFactory, IDisposable
- The SQL schema lives in the **`Fuchs_Database`** SSDT project (source of truth). The backend is SQL-first: it calls stored procedures, table-valued types (e.g. `fds__tt__bankingtransactions`) and functions via OCORE ADO.NET helpers. - The SQL schema lives in the **`Fuchs_Database`** SSDT project (source of truth). The backend is SQL-first: it calls stored procedures, table-valued types (e.g. `fds__tt__bankingtransactions`) and functions via OCORE ADO.NET helpers.
- When changing a stored procedure's name/parameters or a table type, update both the SSDT project and the calling C# in the same change, and keep the MT940/CAMT banking column mappings aligned with `fds__tt__bankingtransactions`. - When changing a stored procedure's name/parameters or a table type, update both the SSDT project and the calling C# in the same change, and keep the MT940/CAMT banking column mappings aligned with `fds__tt__bankingtransactions`.
- The SSDT project is the compile step for all T-SQL: it references the `master` system dacpac and treats T-SQL warnings as errors (`TreatTSqlWarningsAsErrors`, ADR 0036), so an unresolved or ambiguous reference fails `msbuild Fuchs_Database/FuchsDatabase.sqlproj` instead of surfacing at runtime.
+81
View File
@@ -0,0 +1,81 @@
---
status: Active
lastUpdated: 2026-09-17
applyTo:
- "Fuchs/Controllers/IntranetController.Specialties.cs"
- "Fuchs/js/intranet/modules/fis.specialties*"
- "Fuchs/js/intranet/fis_main_menu.js"
- "Fuchs/js/intranet/fis_main_go.js"
relatedDecisions:
- "0027-admin-invoice-file-backup-and-clear.md"
---
# Admin "Specialties" page (`/specialties`)
## Summary
An admin-only page that surfaces the otherwise hidden maintenance actions as
cards, each a small form. It exists so support does not need SSMS or a
hand-crafted POST for actions like resetting an invoice's stored PDF. It is
a regular lazily loaded frontend module (`fn = specialties`), gated like the
rest of the Administration menu (`fds_sys > 4`), and reachable both from the
menu (Administration → Spezialfunktionen) and by opening the URL
`/specialties` directly.
The menu entry is deliberately hidden: it only appears when the Administration
dropdown is opened with **Shift** held (Shift+click, or Shift+Enter/Space on
the focused toggle); a plain open hides it again
(`$fis.bindShiftOnlyMenuItems` in `fis_main_menu.js`, driven by the item's
`lclass: 'shift-only'`). This keeps the maintenance actions out of casual
reach without adding another authorization level — the item is still part of
the menu array, so the `/specialties` deep link is unaffected.
## How it works
- **Module load** follows the standard contract (see
`admin-system-status.md`): the menu item `init:specialties` →
`$ocms.init('specialties')` → `POST /do/specialties/auth` → when
`manage > 0`, import `web/fis.specialties.de.js` + `fis.specialties.css`
and call `init2`. `IntranetController.Specialties.cs` answers only that
auth probe (same `GetSystemAdminAuthAsync` gate as the Admin module) and
401s everything else.
- **Deep link.** `IntranetController.Index` renders the same SPA shell for
every `/{fn}` URL and ignores `fn`; there is no client router. `fis_main_go.js`
therefore calls `$fis.openModuleFromPath()` once the admin menu has settled:
it takes the first path segment and calls `$ocms.init(fn)` **only if the
user's menu offers an `init:<fn>` item** (admin dropdown included). A
non-admin opening `/specialties` sees the dashboard, as before; arbitrary
paths never trigger a module load.
- **Cards.** `fis.specialties.js` keeps an array `cards` of builder
functions. Each card is `title + description + <form> + result box`;
`$spc.submit(c, { button, confirm, run, report })` wires HTML5 validation,
a `confirm()`, the `postXT` call (`run(values)` returns the request) and
the inline result (`report(response)` → `{ ok, lines }`). The card calls
the action's **real** endpoint — every such endpoint enforces the admin
gate itself; the page adds no authority.
- **Current cards**
- *Rechnungsdatei zurücksetzen (Soft-Delete)* → `POST
/do/admin/backupclearinvoicefile/<internal Id>` (ADR 0027). Shows the
outcome and the blob backup URI, or the German text for the service's
`reason` (`invoice_not_found`, `not_final`, `external`, `no_file`,
`db_error`). An invoice without a stored file (`hasFile` from
`fds__getInvoice`) is answered `no_file` before any blob copy, so
repeated clicks leave no spare `backup_*` blobs.
- **Adding a card:** append a builder to `cards`, add its texts to
`fis.specialties_txt_de.js`, run `npx gulp min`. No server change is
needed unless the action itself is new.
- **Build:** bundle entries in `Fuchs/bdlconfig.json`
(`intranet:specialties`); generated files under `wwwroot/web/`.
## Key files
- `Fuchs/Controllers/IntranetController.Specialties.cs` — auth probe.
- `Fuchs/Controllers/IntranetController.cs` — `"specialties"` case in `Do`.
- `Fuchs/js/intranet/modules/fis.specialties.js` / `_txt_de.js` / `.scss`.
- `Fuchs/js/intranet/fis_main_menu.js` — menu item + `openModuleFromPath`.
- `Fuchs/js/intranet/fis_main_go.js` — deep link after menu settles.
- `Fuchs/js/intranet/fis_texts_gui_de.js` — `m_specialties` label.
## Related decisions
- [0027 — Admin invoice-file backup-and-clear action](../Decisions/0027-admin-invoice-file-backup-and-clear.md)
+37 -6
View File
@@ -32,7 +32,10 @@ Access is restricted to users whose `fds_sys` module authorization is greater th
does it push a single "Administration" dropdown entry into `$ocms.ocmsmenu` — containing this does it push a single "Administration" dropdown entry into `$ocms.ocmsmenu` — containing this
module's "Systemüberwachung" (`init:admin`) item alongside `usr`'s "Benutzerverwaltung" module's "Systemüberwachung" (`init:admin`) item alongside `usr`'s "Benutzerverwaltung"
(`init:usr`) item — and re-render `#mainmenu`. Users below the threshold never see the (`init:usr`) item — and re-render `#mainmenu`. Users below the threshold never see the
dropdown and never fetch either module's script. dropdown and never fetch either module's script. Within the dropdown, "Systemüberwachung"
(like "Spezialfunktionen") is a `shift-only` item: it is listed only when the dropdown is
opened with **Shift** held (`$fis.bindShiftOnlyMenuItems`); the `/admin` deep link is
unaffected. This is a convenience/accident guard, not an authorization level.
2. **Server-side gate** — every `/do/admin/*` endpoint resolves 2. **Server-side gate** — every `/do/admin/*` endpoint resolves
`fis_getModuleAuth('fds_sys', authuser)` and returns 401 unless it is > 4. The only `fis_getModuleAuth('fds_sys', authuser)` and returns 401 unless it is > 4. The only
exception is `admin/auth`, which returns `{ manage: 0 }` for unauthorized users so the exception is `admin/auth`, which returns `{ manage: 0 }` for unauthorized users so the
@@ -47,7 +50,7 @@ click "Administration" → $ocms.init('admin')
$ocms.admin.init2() → init3() renders the page $ocms.admin.init2() → init3() renders the page
POST /do/admin/status → { info, probes } full snapshot + all probes POST /do/admin/status → { info, probes } full snapshot + all probes
per-card "Aktualisieren" → POST /do/admin/probe/<component> → { probe } per-card "Aktualisieren" → POST /do/admin/probe/<component> → { probe }
"Test-E-Mail senden" → POST /do/admin/testmail (to/subject/body) → { result } "Test-E-Mail senden" → POST /do/admin/testmail (to/subject/body/attachpdf) → { result }
``` ```
### Backend (`ISystemStatusService` / `SystemStatusService`, scoped) ### Backend (`ISystemStatusService` / `SystemStatusService`, scoped)
@@ -60,7 +63,7 @@ click "Administration" → $ocms.init('admin')
(`status` = `ok`/`error`/`disabled`/`unconfigured`, `ok`, `message`, `detail`, `durationMs`, (`status` = `ok`/`error`/`disabled`/`unconfigured`, `ok`, `message`, `detail`, `durationMs`,
optional `metrics`). They never throw — failures are captured in the result. `database` runs optional `metrics`). They never throw — failures are captured in the result. `database` runs
`SELECT @@SERVERNAME, DB_NAME(), SUSER_SNAME()`; `email` calls `SELECT @@SERVERNAME, DB_NAME(), SUSER_SNAME()`; `email` calls
`IComService.VerifyCredentialsAsync` (GET `{BaseUrl}/mailer/fc?fn=verify` with the configured `IComService.VerifyCredentialsAsync` (GET `{BaseUrl}/api/mailer/verify` with the configured
Basic-auth account/token — see the Mailer API contract) and only turns `ok` when the Mailer API Basic-auth account/token — see the Mailer API contract) and only turns `ok` when the Mailer API
itself confirms the credentials (`ok:true`); when `ProcessWebComSettings.ServerId` is configured itself confirms the credentials (`ok:true`); when `ProcessWebComSettings.ServerId` is configured
it is sent along as `&serverid=...` so the API also confirms this server belongs to the account it is sent along as `&serverid=...` so the API also confirms this server belongs to the account
@@ -74,12 +77,40 @@ click "Administration" → $ocms.init('admin')
count** for the invoice/reminder containers, surfaced as `metrics` and totalled in the message); count** for the invoice/reminder containers, surfaced as `metrics` and totalled in the message);
`mfr` calls `IMfrClientFactory.Create().GetEntities()`. The generic `metrics` (label/value pairs) `mfr` calls `IMfrClientFactory.Create().GetEntities()`. The generic `metrics` (label/value pairs)
is how a probe reports extra facts for display — the blob probe uses it for the file counts. is how a probe reports extra facts for display — the blob probe uses it for the file counts.
- `SendTestEmailAsync(to, subject, body)` HTML-encodes the body and sends via `IComService`, so - `SendTestEmailAsync(to, subject, body, attachPdf)` HTML-encodes the body and sends via
the `Fuchs:Email:OverrideRecipient` redirect applies exactly as for any other mail; the result `IComService`, so the `Fuchs:Email:OverrideRecipient` redirect applies exactly as for any other
reports the requested recipient and, when active, the override target. mail; the result reports the requested recipient and, when active, the override target.
With `attachPdf` (dialog select "PDF-Anhang mitsenden", default *Ja*; absent form value ⇒ on)
a one-page letter PDF (`Fuchs-Intranet-Testmail.pdf`) is rendered in memory via
`IPdfService.WriteLetterAsync` + `DocToPdfBytes` — standard Fuchs letterhead, the recipient in
the address window, `Datum`/`Umgebung`/`Nummer=admin_test` in the admin block, the mail's own
subject and body as the letter text; no DB or stored document is touched. This exercises the
Mailer's multipart/attachment path, so per ADR 0028 `sent` is only `true` when the Mailer also
confirmed the attachment (`attachmentsProcessed == 1`); the result's `attachmentName` says
whether an attachment was part of the send. A PDF rendering failure is reported in the result
(`sent=false`, message names the error) and nothing is sent.
- Every probe emits the `fuchs.systemstatus.probes` counter tagged by component + status and an - Every probe emits the `fuchs.systemstatus.probes` counter tagged by component + status and an
`systemstatus.probe` activity span. `systemstatus.probe` activity span.
### Cards on the page
The page renders one non-probe system card, five refreshable connectivity cards (in this
order), and the non-refreshable startup-checks card. UI labels are from `fis.admin_txt_de.js`.
| Card (label) | Shows | Data source |
| --- | --- | --- |
| System | Hostname, OS, .NET framework, Environment, Test-Deployment flag, Prozess-ID, Laufzeit/Start (UTC) | `GetInfo()` — `Environment`/`Process.GetCurrentProcess()`, no network call |
| SQL-Datenbank (`database`) | Server, Datenbank, Login (never the password) | Config: `fuchs_fds_ConnectionString` parsed. Probe: `SELECT @@SERVERNAME, DB_NAME(), SUSER_SNAME()` |
| E-Mail-Dienst (`email`) | Mailer aktiv, Base-URL, Account, Server-ID, Token hinterlegt, OverrideRecipient/Umleitung aktiv | Config: `ProcessWebComSettings` / `FuchsEmailSettings`. Probe: `IComService.VerifyCredentialsAsync` → `GET {BaseUrl}/api/mailer/verify` at ProcessWeb, `ok` only when the Mailer API confirms account (+ server-id when configured) |
| Azure Blob Storage (`blob`) | Aktiviert, Konfiguriert, Rechnungs-/Mahnungs-Container, per-container file counts (Metrics) | Config: `AzureBlobStorageSettings` / `AzureBlobStorage_ConnectionString`. Probe: `IBlobStorageService.CheckConnectivityAsync` (account-info + blob count per container) |
| MFR (ERP) (`mfr`) | Host, Zugangsdaten hinterlegt, Sync aktiv | Config: `Fds:MFR_host` / `Fds:MFR_UserName` / `Fds:SyncEnabled`. Probe: `IMfrClientFactory.Create().GetEntities()` |
| Azure Key Vault (`keyvault`) | Vault-URI, App-Präfix, Verwaltete Secrets (count), Client registriert | Config: `SecretManagement:VaultUri`/`AppName`/`ManagedSecretKeys`, whether a `SecretClient` is DI-registered. Probe: reads the first key in `ManagedSecretKeys` via `SecretClient.GetSecretAsync` |
| Startup-Prüfungen (non-refreshable) | OK/Fehler/übersprungen per check: Key Vault, SQL-Datenbank, MFR, Spire.PDF-Lizenz, E-Mail (Startup-Probe) | `StartupCheckReporter.Latest`, captured once by `StartupSelfTestService` at boot; see next section |
Each connectivity card also carries a colored status pill (`ok`/`error`/`disabled`/`unconfigured`)
and a per-card "Aktualisieren" button (`POST /do/admin/probe/<component>`); the topbar "Alle
prüfen" button re-runs all five in parallel via `POST /do/admin/status`. The E-Mail-Dienst card
additionally hosts the test-email dialog (see `SendTestEmailAsync` below).
### Startup-checks widget (non-refreshable) ### Startup-checks widget (non-refreshable)
`StartupSelfTestService` runs the boot self-test once (Key Vault / Database / MFR / PDF-license / `StartupSelfTestService` runs the boot self-test once (Key Vault / Database / MFR / PDF-license /
mailer, gated by `Fuchs:StartupChecks:*`). It now also writes its outcome to the singleton mailer, gated by `Fuchs:StartupChecks:*`). It now also writes its outcome to the singleton
@@ -0,0 +1,99 @@
---
status: Active
lastUpdated: 2026-09-16
applyTo:
- "Fuchs/Program.cs"
- "Fuchs/Logging/**"
- "Fuchs/code/FuchsIntranet.cs"
- "Fuchs/Controllers/**"
- "Fuchs/Observability/FuchsTelemetry.cs"
relatedDecisions:
- "0003-structured-diagnostic-logging.md"
- "0003-interrupting-exceptions-notify-the-user.md"
- "0004-opentelemetry-observability.md"
- "0023-user-action-failures-persisted-to-db-log.md"
- "0024-multilogger-sql-log-sink.md"
---
# Error logging pipeline
## Summary
A `_logger.LogWarning`/`-Error`/`-Critical` call anywhere in the app — or in OCORE code called
from it — reaches three places automatically: the always-on file sink, a DB row in
`[dbo].[fds__debuglog]` (what support actually queries), and, for a user-initiated action that
threw unhandled, a SignalR toast to the user. One `ILogger` call is the single thing a call site
writes; nothing calls a stored procedure directly for attribution logging.
## How it works
### The two `ILoggerProvider`s
- **`FuchsLoggerProvider`** (`Fuchs/Logging/FuchsLoggerProvider.cs`, [0003](0003-structured-diagnostic-logging.md)):
every level ≥ Debug → `Debug.WriteLine` + a rolling-free text file (`logs/AppLog.txt` /
`logs/ErrorLog.txt`). Always on, no DB dependency.
- **`OCORE.MultiLogger.SqlLoggerProvider`** ([0024](0024-multilogger-sql-log-sink.md)): registered
in `Program.cs` via `builder.Logging.AddSqlLogger()`, gated by
`Fuchs:Logging:SqlSink:Enabled` (default on), filtered to `LogLevel.Warning`+. Writes via
`EXECUTE [dbo].[fds__admin_logdebug_ext]` into `[dbo].[fds__debuglog]` — the same table the
legacy, now-unused `fds__admin_logdebug` proc wrote to.
### Attribution: EventId, category, scope
- **`CodeReference`** (the row's short label) is `COALESCE(EventId.Name, ILogger category,
'MultiLogger')`, computed in the stored procedure. Give a log call an explicit `EventId` when
the call site should be individually greppable:
```csharp
_logger.LogError(new EventId(0, "SQL Error in getSQLDataSet"), ex, "SQL error in {Procedure}: ...", ...);
```
See `FIS_SQLOptions.OnError`, `IntranetController.Do`'s catch-all, `PdfPreviewAsync`, and the
`bam/up` (banking upload) handlers for the pattern — each preserves the exact string the old,
explicit `debug_log()` calls used, so existing saved queries over `CodeReference` still match.
- **`RequestLogScopeMiddleware`** (`Fuchs/Logging/RequestLogScopeMiddleware.cs`, registered after
`app.UseAuthentication()`) pushes `{requestId, path, authuser}` as an `ILogger` scope for the
whole request. Every log line written while handling it — including ones written deep inside
OCORE's SQL helpers — carries that automatically, without the call site doing anything.
`authuser` specifically lands in the dedicated `fds__debuglog.AuthUser` column (OCORE's
`MultiLogger_base.Log` picks the `"authuser"` scope key out into `LogEntry.AuthUser`); the rest
of the scope + any `{Placeholder}` args + an explicit `data:` object are merged into one redacted
JSON blob (`fds__debuglog.Data`).
- **No request/service-scoped logger available** (`new FIS_SQLOptions()` — ~15 call sites in
`Services/*.cs` construct it this way): `FIS_SQLOptions.OnError` falls back to the static
`OCORE.Logging.Logger` (set once in `Program.cs` to `app.Logger`), so those SQL errors are still
attributed, just under a less specific category.
### Placeholder naming
Always `{Foo}`, never `{@Foo}`. Plain `Microsoft.Extensions.Logging` (not Serilog) does not treat
`@` as a destructuring operator — it stays a literal leading character of the resulting property
name (`"@Foo"`), which is confusing in the serialized `Data` column.
### Redaction and cost
`OCORE.MultiLogger.LogRedaction.SerializeRedacted` masks property names matching known-sensitive
fragments/segments (`password`, `secret`, `token`, `pin`, `otp` as a whole identifier segment —
not substrings like `Mapping`/`HotPath`) at any nesting depth, degrades one unserializable context
entry to its `ToString()` instead of losing the whole payload, and tolerates self-referencing
graphs. `LogEntry.dataSerialized` is computed lazily — at most once, only if a sink actually reads
it — so a Warning+ call that no sink is currently listening for costs nothing extra to serialize.
### User-visible failure surface (unrelated to the DB write, [0023](0023-user-action-failures-persisted-to-db-log.md))
- `IntranetController.Do`'s catch-all publishes a SignalR toast (`IEventService.UserIssueAsync`)
for any unmodeled exception, for authenticated users only.
- A 500 response carries its message in the `x-ocms-desc` header (`ServerError(msg)` →
`EncodeResponseDescription`), which the frontend's default failure handler
(`$ocms.failure`/`oci_basic.js`) reads.
- PDF-preview endpoints go through the shared `PdfPreviewAsync` guard: logs, counts
`fuchs.pdf.previews{what,outcome}` (`success`/`gone`/`failure` — an expired-draft `410` is its
own `gone` outcome, not a render failure), toasts an authenticated caller, and returns a
message with the innermost exception text (omitted for the handful of preview endpoints
reachable without authentication — see `_allowedGet` in `IntranetController.cs`).
## Configuration
- `Fuchs:Logging:SqlSink:Enabled` (bool, default `true`) — off switch for the SQL sink without a
redeploy.
- SQL sink connects via `fuchs_fds_ConnectionString` (same connection as everything else), proc
`dbo.fds__admin_logdebug_ext`, `TruncateTextAt = 4000`.
## Extending this
- Adding a new sink (email/SMS on Critical, a rolling file replacing `FuchsLoggerProvider`) is a
registration in `Program.cs` — `OCORE.MultiLogger` already ships `EmailLoggerOptions`/
`SMSLoggerOptions`/`FileLoggerProvider`.
- A new place that needs DB attribution just calls `_logger.LogWarning`/`-Error` with an
`EventId` if it wants a specific `CodeReference` — never add a second, explicit stored-procedure
call next to it; that duplicates the row once the SQL sink picks the `ILogger` call up.
+22 -4
View File
@@ -1,6 +1,6 @@
--- ---
status: Active status: Active
lastUpdated: 2026-09-09 lastUpdated: 2026-10-01
applyTo: applyTo:
- "Fuchs/Services/InvoiceDraft*" - "Fuchs/Services/InvoiceDraft*"
- "Fuchs/Services/IInvoiceDraft*" - "Fuchs/Services/IInvoiceDraft*"
@@ -99,6 +99,21 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
(`RecomputeLevels`), rendered as an indent class in the editor and a per-level indent in the (`RecomputeLevels`), rendered as an indent class in the editor and a per-level indent in the
PDF. `item.setprice` sums a header's **transitive** descendants. Structure deltas never PDF. `item.setprice` sums a header's **transitive** descendants. Structure deltas never
change values — only order, membership, levels and therefore position numbers. change values — only order, membership, levels and therefore position numbers.
- **Concurrency.** The editor fires patches, state fetches, previews and saves for one token
concurrently, and the session payload (`JObject`/`JArray`, the history list) is not thread-safe.
Every read and write of the payload runs under the session's `SyncRoot`, and everything the
controller serialises afterwards (`BuildState`, `GetHistory`, the `FdsInvoiceData` a preview or
flush uses) is a deep-cloned snapshot taken under that lock — never a live reference. Flushes are
additionally serialised per session on `FlushGate` (a `SemaphoreSlim`, awaited — `SyncRoot`
cannot be held across the registration's `await`): the create/update decision in
`FlushToDbAsync` is read-then-write across that await, so two overlapping saves of a
not-yet-registered draft (double-click on *Zwischenspeichern*, or *Zwischenspeichern* followed
by preview → confirm) would otherwise both call `fds__createInvoice` and leave an orphaned
invoice. The waiting save snapshots **after** it acquires the gate, so it persists the newer
state as an update of the row the first save created. `FlushToDbAsync` reports that decision
(`DraftFlushResult.Updated`) and the controller publishes `InvoiceDraftCreated`/`-Updated` from
it rather than from the session state it saw before the flush. Patches are never blocked by a
running save. `DraftSessionConcurrencyTests` pins both the lock and the gate.
- **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding - **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding
TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after, TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after,
the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`. the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`.
@@ -146,9 +161,12 @@ amount pair.
- **Orchestration** (`ReminderDraftEditService`, scoped) flushes to the DB by reusing - **Orchestration** (`ReminderDraftEditService`, scoped) flushes to the DB by reusing
`IReminderService.RegisterReminderAsync`, and renders previews from a synthesised `IReminderService.RegisterReminderAsync`, and renders previews from a synthesised
`ReminderRegistration` (including the single-invoice `invoices` row the reminder PDF table `ReminderRegistration` (including the single-invoice `invoices` row the reminder PDF table
renders) so a preview needs no DB round-trip. **Note:** `RegisterReminderAsync` is create-only renders) so a preview needs no DB round-trip. A first save runs `fds__createReminder`; every
(there is no `fds__setReminder` update proc), so a re-saved reminder draft does not update the later save of the same session runs `fds__setReminder`, which updates the row in place (version
prior DB row — the primary flow (preview → confirm) flushes once immediately before finalising. bump, same result shape) and refuses — `@Id` NULL → the batch throws → "nicht gespeichert" — once
the reminder is finalised or has a stored file. Flushes are serialised on the session's
`FlushGate` exactly like invoice flushes (see *Concurrency* above), so overlapping saves cannot
create two reminder rows either.
- **Cache/expiry** (`ReminderDraftCache` singleton + `ReminderDraftExpiryService`) mirror the - **Cache/expiry** (`ReminderDraftCache` singleton + `ReminderDraftExpiryService`) mirror the
invoice ones and share the same `Fuchs:DraftEditing` TTL config. invoice ones and share the same `Fuchs:DraftEditing` TTL config.
- **Signals** reuse the shared `DraftPreviewHub` + `IDraftNotifier` unchanged — the token-keyed - **Signals** reuse the shared `DraftPreviewHub` + `IDraftNotifier` unchanged — the token-keyed
@@ -0,0 +1,130 @@
---
status: Active
lastUpdated: 2026-09-17
applyTo:
- "Fuchs/Services/IComService.cs"
- "Fuchs/Services/ProcessWebComService.cs"
- "Fuchs/Services/ProcessWebComSettings.cs"
- "Fuchs/Services/FuchsMailerClient.cs"
- "Fuchs/Services/MailerAttachmentPolicy.cs"
- "Fuchs.Tests/ProcessWebComServiceTests.cs"
- "Fuchs.Tests/FuchsMailerClientTests.cs"
relatedDecisions:
- "0028-mailer-push-com-multipart-and-attachment-confirmation.md"
- "0029-outbound-mail-via-ocore-pwmailer-client.md"
---
# ProcessWeb Mailer interface (outbound email/SMS)
## Summary
All outbound email and SMS leaves Fuchs through `IComService` →
`ProcessWebComService`, which is a thin adapter over the OCORE_web
**`IPWMailerClientService`** (`OCORE_web/comm/pwmailer/`). The client owns
the HTTP contract of the ProcessWeb Mailer API; Fuchs owns what is specific
to Fuchs — the recipient-override safety net, the signature, the attachment
limits, the `fds__emaillog` audit row and telemetry.
The authoritative, server-side contract is the Mailer's own function catalog:
> **https://processweb.de/api/mailer/fc** — subject to change as features are
> added, but kept backward compatible. The OCORE_web client is kept in sync
> with it (its header says so and names the server-side files); read both
> before changing anything here.
## How it works
### Layering
| Layer | File | Responsibility |
|-------|------|----------------|
| Adapter | `Fuchs/Services/ProcessWebComService.cs` | `IComService`: validate recipient, apply `Fuchs:Email:OverrideRecipient`, append signature, build a `PWMailerMessage`, interpret the `PWMailerResult`, write `fds__emaillog`, emit metrics |
| Fuchs client | `Fuchs/Services/FuchsMailerClient.cs` | `PWMailerClientService` subclass; overrides `ValidateMessage` to add the attachment limits (`MailerAttachmentPolicy`) on top of the contract's base rules |
| OCORE client | `OCORE_web/comm/pwmailer/PWMailerClientService*.cs` | endpoints, envelope, JSON vs multipart (unnamed envelope field + one file part per attachment), `attachmentCount`, Basic auth, token resolution, `x-pcw-excd` error headers, retries/timeouts, typed results |
### Configuration
`Fuchs:Mailer` (`ProcessWebComSettings`) stays the single config section. In
`Program.cs` it is mapped onto `PWMailerClientOptions`:
- `BaseUrl` (`https://processweb.de`) + `/api/mailer` → `PWMailerClientOptions.BaseUrl`
- `AccountId` → `Account`, `ServerId` → `ServerId`, `Token` → `Token`
- `TokenSecretName = "Fuchs--Mailer--Token"` — the same Key Vault secret
OCORE secret management already maps into `Fuchs:Mailer:Token`. In
production the configuration value is therefore already the plain token;
the name only matters when the `MANAGED_BY_KEYVAULT` placeholder is still
in place (e.g. locally), where the client loads it on demand.
- `Attachments` (`MaxFileBytes` 10 MB, `MaxMessageBytes` 20 MB, `MaxCount`
10 — the contract defaults) feed `FuchsMailerClient`'s pre-validation.
Registration: `AddPWMailerClient<FuchsMailerClient>()` (singleton, named
`HttpClient` `PWMailerClient`). `ProcessWebComService` stays scoped.
### Sending an email
1. `SendEmailAsync` validates the address, applies the override, appends the
signature, and builds `PWMailerMessage { To, Cc, Subject, Body, Reference,
ComType = "e", Attachments }`. `AttachmentCount` is left `null`, so the
client sends the real count and the Mailer cross-checks it (400 on
mismatch).
2. `FuchsMailerClient.ValidateMessage` runs *inside* `PushAsync` before any
bytes go out: base rules (server id, recipient, subject, body, empty file,
missing extension) plus Fuchs limits (count, per-file size, total size,
forbidden extensions — `MailerAttachmentPolicy.ForbiddenExtensions` is the
contract's quoted subset; the server's list is longer and authoritative).
A violation returns a result with `StatusCode 0`, `Attempts 0` and the
`"; "`-joined rule messages as `Error`.
3. The client posts JSON (no attachments) or multipart (with) to
`POST /api/mailer/push_com`, never retrying a push.
4. The adapter interprets the `PWMailerResult<PWMailerPushReceipt>`:
| Result | `SendEmailAsync` | audit `fds__emaillog` | `fuchs.emails.failed{reason}` |
|--------|------------------|-----------------------|-------------------------------|
| `Success` and (no attachments or `AttachmentsProcessed == sent`) | `true` | `guid = comStackID` | — (`fuchs.emails.sent`) |
| `Success` but `AttachmentsProcessed != sent` | `false` | counts in log, `guid = comStackID` | `attachments-mismatch` |
| `IsConflict` (409: identical message still pending at the Mailer — it will be sent, nothing new queued) | `true` | "Duplicate of a still-pending entry (409) …" | — (`fuchs.emails.sent`) |
| `StatusCode 0`, `Attempts 0`, `Exception is ArgumentException` (refused by pre-validation) | `false` | one line per rule | `validation` |
| `StatusCode 0`, `Attempts 0`, no exception (options unusable / token not resolvable, e.g. Key Vault outage) | `false` | `Mailer client not usable: …` (Error log) | `client-config` |
| any other failure | `false` | `API error {status}: {error}` | `api-error` |
| client threw | `false` | exception detail | `api-error` |
`reference` (`inv_<Id>` etc.) is the audit key and part of the Mailer's
idempotency key (server + reference + content, while the first entry is still
pending). A resend of the identical mail within that window answers 409 and
is reported as **success** — the first entry is delivered; nothing is sent
twice. A resend after delivery started is a new entry. Callers keep reusing
`inv_<Id>` (e.g. `req/resend`); no per-attempt reference is needed.
### SMS and verification
- `SendSmsAsync` pushes `PWMailerMessage { ComType = "s", To = mobile, Body }`
— the base validation requires no subject for `comType "s"`.
- `VerifyCredentialsAsync` → `IPWMailerClientService.VerifyAsync(serverId)`
(`GET /api/mailer/verify[?serverid=…]`), mapped to
`MailerCredentialsCheckResult` for the Admin system-status view.
### Observability
`fuchs.emails.sent` / `fuchs.emails.failed{reason}` with reasons
`invalid-email`, `disabled`, `validation`, `client-config`,
`attachments-mismatch`, `api-error`; `fuchs.email.send.duration{success}`. The OCORE
client additionally logs every failed call at Warning and exposes
`RequestSending`/`ResponseReceived`/`CallCompleted`/`CallFailed` events (not
subscribed by Fuchs today).
## Key files
- `Fuchs/Services/IComService.cs` — interface + `MailerCredentialsCheckResult`.
- `Fuchs/Services/ProcessWebComService.cs` — the adapter.
- `Fuchs/Services/FuchsMailerClient.cs` — `ValidateMessage` override.
- `Fuchs/Services/MailerAttachmentPolicy.cs` — limit rules (pure).
- `Fuchs/Services/ProcessWebComSettings.cs` — `Fuchs:Mailer` incl. `Attachments`.
- `Fuchs/Program.cs` — options mapping + `AddPWMailerClient<FuchsMailerClient>()`.
- `OCORE_web/OCORE_web/comm/pwmailer/` — the client (submodule).
- `Fuchs.Tests/ProcessWebComServiceTests.cs`, `Fuchs.Tests/FuchsMailerClientTests.cs`;
the wire format is tested in `OCORE_web/OCORE_webTests/comm/PWMailerClientServiceTests.cs`.
## Related decisions
- [0028 — push_com multipart attachments and attachment confirmation](../Decisions/0028-mailer-push-com-multipart-and-attachment-confirmation.md)
- [0029 — Outbound mail via the OCORE_web PWMailer client](../Decisions/0029-outbound-mail-via-ocore-pwmailer-client.md)
+75
View File
@@ -0,0 +1,75 @@
---
status: Active
lastUpdated: 2026-09-18
applyTo:
- "Fuchs/Program.cs"
- "Fuchs/appsettings*.json"
- "OCORE/OCORE/Secrets/**"
- "OCORE_web/OCORE_web/Secrets/**"
relatedDecisions:
- "0031-shared-keyvault-dpapi-secret-management.md"
---
# Secret management (Key Vault + DPAPI)
## Summary
Fuchs loads its secrets (connection strings, API keys, mailer tokens, ...)
from the shared Azure Key Vault `pcwkeys` at startup, with a DPAPI-encrypted
local cache as an offline fallback. The mechanism is shared, host-type-agnostic
infrastructure owned by the `OCORE`/`OCORE_web` submodules ([0031](../Decisions/0031-shared-keyvault-dpapi-secret-management.md));
this doc covers how Fuchs specifically wires into it and where to make
changes. The full mechanism reference (naming/encoding rules, DI wiring,
cache format) is maintained independently in
`OCORE_web/.github_instructions/secretsmanagement.instructions.md`.
## How it works
1. `Fuchs/Program.cs` calls `builder.AddSecretManagement()`
(`OCORE_web.Secrets.SecretManagementWebExtensions`) as the first step in
`ConfigureServices`, before `FuchsOcmsIntranet.Initialize(configuration)`
or anything else reads `IConfiguration`.
2. That call, backed by `OCORE.Secrets.Extensions.SecretManagementExtensions`,
registers two configuration sources in order: the local DPAPI cache
(`secrets.cache`, offline fallback) first, then the live Azure Key Vault
(overrides the cache when reachable) using `PrefixKeyVaultSecretManager`
to filter to secrets named `fuchs--...` and map them onto
`IConfiguration` keys.
3. `KeyVaultSyncService` (a registered `BackgroundService`) periodically
re-pulls every key listed in `Fuchs:SecretManagement:ManagedSecretKeys`
from Key Vault, re-writes the DPAPI cache, and calls
`IConfigurationRoot.Reload()` so a running process picks up rotated
secrets without a restart.
4. A secret whose name is only known at runtime (not a fixed
`IConfiguration` key) — e.g. resolved from a DB job record — is instead
fetched directly via `OCORE.Secrets.Extensions.SecretsOnDemandLoader`,
bypassing `ManagedSecretKeys` and the periodic sync entirely.
5. In `Development`, `appsettings.Development.json` (git-ignored) can
override any of these values locally, bypassing Key Vault for that key;
`AssembleConnectionStrings` (`Fuchs/Program.cs`) additionally prefers
`_Dev`-suffixed credential keys so a reachable Key Vault can never hand a
dev machine production DB credentials.
## Key files
- `Fuchs/Program.cs` — `builder.AddSecretManagement()` call site and
`AssembleConnectionStrings`.
- `Fuchs/appsettings.json` — `SecretManagement` block (`VaultUri`,
`AppName: "fuchs"`, `CacheFilePath`, `SyncIntervalHours`,
`ManagedSecretKeys`) plus `"MANAGED_BY_KEYVAULT"` placeholders for every
managed key.
- `OCORE/Secrets/Extensions/SecretManagementExtensions.cs` — shared
Key Vault + DPAPI wiring and DI registration, used by every host type.
- `OCORE/Secrets/Extensions/SecretsOnDemandLoader.cs` — direct, cached,
runtime-name secret lookup outside the `ManagedSecretKeys` sync path.
- `OCORE/Secrets/Sync/KeyVaultSyncService.cs` — periodic re-sync +
`IConfigurationRoot.Reload()`.
- `OCORE/Secrets/Cache/DpapiSecretsCache.cs` — DPAPI encrypt/decrypt of the
local cache file.
- `OCORE_web/Secrets/SecretManagementWebExtensions.cs` — the
`WebApplicationBuilder.AddSecretManagement()` entry point Fuchs calls.
- `OCORE_web/.github_instructions/secretsmanagement.instructions.md` —
canonical mechanism reference (naming/encoding rules, cache format,
"adding a new secret" steps), kept current independently of this doc.
- `.github/copilot-instructions.md` ("Azure Key Vault — Secret Naming") —
the naming rules summarized for this repo, with the `fuchs--` prefix.
## Related decisions
- [0031 — Shared Key Vault + DPAPI secret management via OCORE/OCORE_web](../Decisions/0031-shared-keyvault-dpapi-secret-management.md)
@@ -0,0 +1,77 @@
---
status: Superseded
date: 2026-09-16
applyTo:
- "Fuchs/Controllers/**"
- "Fuchs/code/FuchsIntranet.cs"
- "Fuchs/js/intranet/**"
supersededBy: "0024-multilogger-sql-log-sink.md"
---
> **Superseded by [0024](0024-multilogger-sql-log-sink.md).** The explicit `Fuchs_intranet.debug_log` /
> `fds__admin_logdebug` DB write this decision introduced was retired the same day, once
> [0024](0024-multilogger-sql-log-sink.md) made `ILogger` itself the single path to the DB log
> (`fds__admin_logdebug_ext`, same `fds__debuglog` table). Keeping both would have double-written
> every row. The frontend/`x-ocms-desc`/`EncodeResponseDescription`/preview-timeout decisions below
> are unaffected and still in force — only the *DB write mechanism* changed.
# 0023 — Failures of user actions are persisted to `fds__admin_logdebug`, with the SQL and a readable description
## Context
Support diagnoses production problems from a query over the `fds__admin_logdebug` table
(`EXECUTE [dbo].[fds__admin_logdebug]` via `Fuchs_intranet.debug_log`) — not from the
web server's `logs/ErrorLog.txt` ([0003](0003-structured-diagnostic-logging.md)), which is
rarely reachable. Reviewing the 2026-09-16 production extract of that table showed three gaps:
- Every "SQL Error in getSQLDataSet" row (twelve execution timeouts in one morning) had
`Data = NULL`. `FIS_SQLOptions.OnError` logged its constructor *context* (the fn/id/code,
usually `null`) instead of the *data* OCORE hands over (`new { sql = … }`), so nobody could
tell which statement timed out.
- Unhandled exceptions from `IntranetController.Do` went to `ILogger` only. A customer's
failing "PDF-Vorschau" left no trace in the table at all.
- The browser's generic failure alert reads its description from the `x-ocms-desc` response
header, which the backend never set — so users always saw an empty
"Der Server hat einen Fehler gemeldet:".
## Decision
- **The DB log row must carry what is needed to attribute the failure.** `FIS_SQLOptions`
merges the OCORE payload (the SQL statement) with the caller context into the `Data`
column (`{ sql, context }`). New `OnError` hooks must never drop the statement.
- **Every failure that interrupts a user action is written to `fds__admin_logdebug` too**,
in addition to `ILogger` and the notification required by
[0003](0003-interrupting-exceptions-notify-the-user.md): the `Do` catch-all writes
`Unhandled exception in Do {fn}/{id}` with route, user and the form *field names* (never
values — they can carry tokens/PII); the PDF-preview endpoints go through a shared guard
(`IntranetController.PdfPreviewAsync`) that logs `PDF preview failed (endpoint)` with
ref/user/duration, counts `fuchs.pdf.previews{outcome}` (`success`/`gone`/`failure` — an
expired-draft `410` from inside `render()` is its own `gone` outcome, not `success`, since
it is not the render failure this guard exists to catch) and, for an authenticated caller,
toasts the user.
- **A 500 carries its message in the `x-ocms-desc` header** (`ServerError(msg)` →
`EncodeResponseDescription`: percent-encoded ASCII, spaces as `%20` because the client
maps `+` to space before `decodeURIComponent`). Handlers that return a 500 should pass a
user-readable German message; the frontend shows it verbatim.
- **Preview calls in the frontend never fail silently.** They use a 120 s timeout
(`$inv.pdfPreviewTimeout`), one shared error handler (`$inv.pdfPreviewFailed`) and a
`complete` handler that unfreezes the editor and reports a timeout — `postXT` returns early
on a timeout without calling `error`, so `complete` is the only place it is observable.
## Consequences
- Support can attribute a SQL timeout to its statement straight from the table; a failing
preview is visible as one row with endpoint, reference id, user and duration.
- `debug_log` is a synchronous DB round-trip and is only used on error paths; it must not be
added to hot/success paths (see [0003](0003-structured-diagnostic-logging.md) on the cost
of DB logging).
- `x-ocms-desc` is user-facing: messages must be German, short, and free of secrets. The
innermost exception text is acceptable for an authenticated intranet user, but some
preview endpoints (`req/idoc`, `inv/rdoc`, `inv/rdocn`, `rem/idoc` — see `_allowedGet`
in `IntranetController.cs`) are reachable without authentication; `PdfPreviewFailureMessage`
omits the exception detail for those callers and falls back to the generic message.
## Alternatives considered
- **Enable `FuchsLoggerProvider.DatabaseLoggingEnabled`** so every `ILogger` error lands in
the DB. Rejected: it would write every `LogError` (including expected/handled ones and
background-service noise) with a DB round-trip each, and the provider has no access to the
request context (user, route) that makes a row actionable.
- **Return the message only in the JSON body.** The generic client failure path never parses
the body of a non-2xx response; the header is the existing contract in `oci_basic.js`.
@@ -0,0 +1,114 @@
---
status: Accepted
date: 2026-09-16
applyTo:
- "Fuchs/Program.cs"
- "Fuchs/Logging/**"
- "Fuchs/code/FuchsIntranet.cs"
- "Fuchs/Controllers/**"
- "Fuchs_DataService/FdsMfr.cs"
- "OCORE/OCORE/logging/**"
- "Fuchs_Database/dbo/Tables/fds__debuglog.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__admin_logdebug_ext.sql"
supersededBy: ""
---
# 0024 — `ILogger` is the single path to the DB log, via the OCORE MultiLogger SQL sink
## Context
[0023](0023-user-action-failures-persisted-to-db-log.md) fixed the immediate gaps (SQL statement
missing from the log row, unhandled `Do()` exceptions and PDF-preview failures never reaching the
DB) by having a handful of call sites call **both** `_logger.LogError(...)` **and** the explicit
`Fuchs_intranet.debug_log(...)` (→ `EXECUTE [dbo].[fds__admin_logdebug]`) for the same failure.
That works, but only for the call sites someone remembered to wire up by hand, it writes every
attributed failure to `fds__debuglog` twice as soon as any generic DB sink is added on top, and it
leaves the ~15 `new FIS_SQLOptions()` call sites (no logger, no context — see `Services/*.cs`)
without DB attribution at all, since their `dbLog` closure bypassed `ILogger` entirely.
OCORE ships a generic `ILoggerProvider` pipeline for exactly this (`OCORE.MultiLogger`,
`SqlLoggerProvider` + `FileLoggerProvider`) that other ProcessWeb projects (VM_Regatta) already
use: every `_logger.LogX(...)` call, from anywhere in the app or its dependencies (OCORE's own
SQL helpers included), can be mirrored to a SQL sink automatically, with per-provider level
filtering, structured context merging (scopes + `{Placeholder}` args + an explicit `data:`
object), and redaction. It was not previously wired into Fuchs.
## Decision
- **`ILogger` is the only thing a call site writes.** No code calls `Fuchs_intranet.debug_log`
(removed) or a stored procedure directly for attribution logging. `_logger.LogWarning`/`-Error`/
`-Critical` is sufficient; the SQL sink below persists it.
- **The OCORE MultiLogger SQL sink is registered at Warning+**, gated by
`Fuchs:Logging:SqlSink:Enabled` (default `true`), writing through the new
`[dbo].[fds__admin_logdebug_ext]` proc into the **same** `[dbo].[fds__debuglog]` table
the retired `fds__admin_logdebug` wrote to — existing report queries over that table keep
working, now enriched with `LogLevel`/`Machine`/`Category`/`AuthUser`/`IsDebug` columns.
`fds__admin_logdebug` itself is left in place (unused by the app, but not dropped — a
compatibility no-op for anything external still calling it).
- **`FuchsLoggerProvider`'s file sink (0003) is unchanged** and stays the always-on baseline;
the SQL sink is additive, not a replacement.
- **A call site that wants its own CodeReference gives the log call an explicit `EventId`**
(`new EventId(0, "SQL Error in getSQLDataSet")`, etc.) — its `Name` becomes the row's
`CodeReference`, preserving the exact strings the retired `debug_log` calls used, so nothing
downstream (dashboards, saved queries) had to change. Falls back to the `ILogger` category
(new `LogEntry.Category`, OCORE-side), then the constant `'MultiLogger'`.
- **`RequestLogScopeMiddleware`** (`Fuchs/Logging/`, registered after `UseAuthentication()`)
pushes `{requestId, path, authuser}` as an `ILogger` scope for the whole request — every log
line written while handling it, including ones written deep inside OCORE (SQL timeouts, PDF
render failures), is attributed automatically without the call site doing anything. `authuser`
specifically is picked up by OCORE's `MultiLogger_base.Log` into the dedicated
`LogEntry.AuthUser` → `fds__debuglog.AuthUser` column, not just the serialized JSON blob.
- **`FIS_SQLOptions.OnError` falls back to the static `OCORE.Logging.Logger`** (already set once
in `Program.cs`) when no request/service-scoped `ILogger` was supplied, so the `new
FIS_SQLOptions()` call sites keep working exactly as attributed as the ones that pass a logger.
- **`Fuchs_DataService/FdsMfr.cs`'s `FdsSqlOptions.OnError`** — the MFR-sync worker's equivalent of
`FIS_SQLOptions`, and the actual source of the deadlock/timeout rows the original log review
found — had the exact same problem in reverse: it called *both* `logger?.LogError(...)` *and*
`FdsDebug.DebugLog(...)` (a second, direct `EXECUTE [dbo].[fds__admin_logdebug]`) for every SQL
error. Since the worker is hosted inside this same web app (ADR 0010) and therefore shares the
SQL sink, that combination would double-write every row. Fixed the same way: `ILogger` only,
with the same static-logger fallback, `EventId.Name = procedure` (unprefixed, matching what the
direct `DebugLog` call used to pass).
- **`FdsDebug.DebugLog`, used elsewhere in `Fuchs_DataService`** (`FdsMfr.cs`, `FdsShared.cs`) was
deliberately **not** migrated — those call sites don't separately call `ILogger`, so there is no
duplication there today, and moving nine call sites onto the generic pipeline was out of scope
for closing the one hazard (`FdsSqlOptions.OnError`) this ADR set out to fix. It still writes
directly to the legacy `fds__admin_logdebug` proc (same `fds__debuglog` table) plus a local
`DebugLog.txt`. Revisit if a future change adds an `ILogger` call alongside one of those sites.
- **OCORE-side hardening that made this safe to turn on** (see the code-review this ADR grew out
of): `LogRedaction` masks sensitive keys by *segment*, not substring (`Pin`/`Otp` as a whole
identifier segment, not `Mapping`/`HotPath`), degrades one unserializable context entry to its
`ToString()` instead of dropping the whole payload, and tolerates a self-referencing graph.
`LogEntry.dataSerialized` is computed lazily (at most once, only if a sink actually reads it) so
the per-call context-merging cost is paid only when something is written. Scope/state collection
never throws into the caller. `SqlLoggerProvider`'s new `IncludeCategory` option is **off by
default** in OCORE (other consumers' stored procedures don't declare `@category`) — Fuchs turns
it on explicitly since its proc does.
- **Never use `{@Foo}` placeholders.** Plain `Microsoft.Extensions.Logging` (what OCORE.MultiLogger
and Fuchs use — not Serilog) does not treat `@` as a destructuring operator; it stays a literal
leading character of the property name (`"@Foo"`), which is confusing in the serialized JSON.
Use `{Foo}`.
## Consequences
- One `_logger.LogError(...)` call is enough; adding a second, explicit DB write next to it
duplicates the row once the SQL sink is on. Code review should flag a reintroduced
`debug_log`-style call.
- A log call that should be individually attributable in `fds__debuglog.CodeReference` needs an
explicit `EventId`; without one it falls back to the (often less specific, but still useful)
`ILogger` category.
- The SQL sink is a fire-and-forget async write with a 2-minute retry cooldown on failure
(`SqlLoggerProvider`) — during a DB outage it goes quiet for that window, same as any other
write to that DB. The file sink (0003) remains the log that is never gated on the DB being
reachable at all.
- `Fuchs:Logging:SqlSink:Enabled` is the escape hatch if the sink ever needs to be turned off
without a deploy (e.g. investigating whether it is itself contributing to DB load).
- OCORE's `MultiLoggerOptions.SQL.IncludeCategory` is a new, opt-in, backward-compatible knob;
other `SqlLoggerProvider` consumers (VM_Regatta) are unaffected unless they opt in and add a
matching `@category` parameter to their own stored procedure.
## Alternatives considered
- **Keep the explicit `debug_log` write and just fix its parameter bug (0023's original scope).**
Rejected once the generic SQL sink became the actual plan — keeping both would permanently
double-write every attributed failure, and still leave the `new FIS_SQLOptions()` call sites
unattributed.
- **`FuchsLoggerProvider.DatabaseLoggingEnabled = true`** (0003's prepared-but-disabled DB path).
Rejected again here for the same reason 0023 rejected it: no per-request context (user, route),
and it would fire on every `LogError` including background-service noise with no Warning+ gate.
@@ -0,0 +1,79 @@
---
status: Accepted
date: 2026-09-16
applyTo:
- "Fuchs/Controllers/IntranetController.Invoices*.cs"
- "Fuchs/Controllers/IntranetController.Requests.cs"
- "Fuchs/Services/InvoiceService.cs"
- "Fuchs_Database/dbo/Stored Procedures/fds__getReportDocument.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__setInvoiceFile.sql"
supersededBy: ""
---
# 0025 — Invoice document endpoints regenerate a missing final PDF on read
## Context
A finalised invoice's PDF is rendered exactly once, at finalisation
(`req/sconf` → `StoreInvoiceDocumentFileAsync` → `fds__setInvoiceFile`), and
stored in `fds__invoices.[file]` (primary) plus blob storage (archive). If that
single write fails — observed on 2026-09-16 for `W7VAL`/`R2026-0258`, where the
`fds__setInvoiceFile` call timed out under DB load — the invoice is left
finalised with `[file] = NULL` and nothing ever retries.
The read endpoints then disagreed: `req/idoc` regenerated a missing file
(a fallback carried over from the legacy VB `fuchs_ocms_intranet_requests.vb`
"idoc" case), while `inv/rdoc` — the endpoint the invoice list's preview icon
actually calls — read `fds__getReportDocument` and returned a permanent 404.
## Decision
Both invoice document read endpoints (`req/idoc`, `inv/rdoc`) regenerate and
persist the PDF via `StoreInvoiceDocumentFileAsync` when no stored bytes exist,
**only** when the invoice is
- **finalised** (`IsFinal`, i.e. `!FdsInvoiceData.IsDraft`), and
- **not external** (`IsExternal` false — an MFR-sourced document has no
template to regenerate from; a missing MFR file stays a 404).
A read is therefore allowed to write `[file]`, upload the blob and emit
`InvoiceFileCreated` — the same side effects finalisation would have had.
Every failure inside `StoreInvoiceDocumentFileAsync`/`StoreReminderDocumentFileAsync`
(render threw, render produced no bytes, DB write threw, DB write matched 0
rows) is logged at Error level individually, because the method signals all
four to callers with the same empty array. The 0-rows case is detected via
the procs' existing `OUTPUT inserted.[Id]` result set (read with
`getSQLDatatable_async`; empty ⇒ the `UPDATE ... WHERE [file] IS NULL`
matched nothing). Rows-affected is **not** usable for this: both procs run
`SET NOCOUNT ON`, so `ExecuteNonQuery` returns -1 whether or not the
`UPDATE` matched, which is why `setSQLValue_async` could never distinguish
a no-op from a write and the original incident was invisible to every log.
## Consequences
- **Drafts are never persisted by a read.** `fds__setInvoiceFile` writes
`WHERE [file] IS NULL` and `fds__setInvoiceFinal` never clears `[file]`; a
draft render stored early would make the later final write a no-op and the
invoice would carry the watermarked draft PDF forever. The `!IsDraft` gate is
load-bearing — do not relax it.
- The regenerated PDF is rendered from the invoice's *current* data. That is
acceptable because a finalised invoice is immutable in the editor; it is not
acceptable for anything mutable.
- A read on a cold miss now costs a render plus a DB write, so it is exposed
to the same `fds__setInvoiceFile` timeout that caused the miss. The client
preview timeout (`$inv.pdfPreviewTimeout`) must stay generous (120 s).
- `inv/rdoc` still serves external (MFR) documents purely from
`fds__getReportDocument`; regeneration never applies to them.
## Alternatives considered
- **Retry at finalisation instead of on read.** Would cover the store failure
but not an already-damaged row; regeneration on read heals both and matches
the behaviour `req/idoc` already had.
- **Regenerate drafts too, to mirror `req/idoc`'s render path.** `req/idoc`
*renders* a draft on the fly without storing it; persisting a draft through
`fds__setInvoiceFile` is what causes the permanent-draft defect above.
- **Return 404 and rely on the `_BackupAndClearInvoiceFile` recovery links.**
That proc is a manual-only maintenance tool, and the `inv/rdoc?create=1`
link it prints was never implemented on that endpoint.
@@ -0,0 +1,74 @@
---
status: Accepted
date: 2026-09-16
applyTo:
- "Fuchs/Services/FuchsWidgetService.cs"
- "Fuchs/Services/ProcessWebComService.cs"
- "Fuchs_Database/dbo/Functions/fds__fn_unpaidInvoices.sql"
- "Fuchs_Database/dbo/Functions/fds__fn_InvoicePaymentAmount*.sql"
- "Fuchs_Database/dbo/Functions/fds_wgd_*.sql"
- "Fuchs_Database/dbo/Functions/fds_wdg_*.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__logEmail.sql"
- "Fuchs_Database/Scripts/**"
supersededBy: ""
---
# 0026 — Dashboard widget SQL is set-based (no per-row scalar UDFs); `fds__emaillog.config` is a JSON contract
## Context
Two dashboard widgets were failing in the DB log (`fds__debuglog`, 2026-09-16):
1. **`wdg_invoices_overdue_sum`** timed out after the widget command timeout of 90 s. Its SQL called
`[dbo].[fds__fn_InvoicePaymentAmount](Id)` once per row of `fds__fn_unpaidInvoices()`. That scalar
UDF declares a table variable, so SQL Server 2019 cannot inline it (`sys.sql_modules.is_inlineable = 0`)
and re-executes the multi-statement TVF `fds__fn_bankingtransactions` — a full pass over
~11 k banking rows plus a `STRING_SPLIT` cross-apply over the settings heap — for **every** invoice:
≈0.5 s × 187 unpaid invoices ≈ 100 s measured. `wdg_unbez_akonto` had the same pattern (≈5 s,
hidden only by `daycache = 1`). The same total computed set-based (one TVF pass, `GROUP BY InvId`,
joined to the invoices) takes < 0.5 s and returns the identical figure.
2. **`wgd_lastEmails`** raised *JSON text is not properly formatted*. `fds_wgd_lastEmails` parses
`fds__emaillog.config` with `OPENJSON`/`JSON_QUERY`, but `ProcessWebComService` — the replacement
for the legacy mailer — wrote `config = ''` on every path. One such row was enough to error the
whole widget, and the audit trail had lost recipient/subject/attachment names for every email sent
through the new service.
Widget definitions are **data** (`fis_widgets.sql`), not schema, so there is no compile-time check
that a widget's SQL is cheap or that the columns it parses exist.
## Decision
- **Widget SQL and the functions backing it are set-based.** Per-invoice payment totals come from the
inline TVF `[dbo].[fds__fn_InvoicePaymentAmounts]()` (one banking pass, grouped by `InvId`) or from
the `[PaymentAmount]`/`[OpenAmount]` columns `fds__fn_unpaidInvoices()` now exposes. The scalar
`fds__fn_InvoicePaymentAmount(_full)` remains for single-invoice callers (invoice/reminder
preparation, per-row `paymentstatus = 'p'` cells in the invoice lists) but must not be called
per row from a widget or any other query that iterates a whole invoice set.
- **`fds__emaillog.config` is a JSON contract**, written by `ProcessWebComService.BuildAuditConfig`
in the legacy mailer's shape — `{subject, from, to[], cc[], bcc[], attachments[]}` with addresses
as `"Name" <email>` and attachments as file names only (never the body, never attachment bytes) —
on **every** audit path (success, API error, exception, invalid address, service disabled). SQL
consumers of the column must still tolerate non-JSON text (`IIF(ISJSON(x)=1, x, N'{}')` before
parsing), because `config` is `NOT NULL` and older rows/other writers may hold `''`.
- **Widget-data migrations live in `Fuchs_Database/Scripts/`** as idempotent, dated T-SQL files
(`<None Include>` in the sqlproj, not part of the schema build) and are run once per environment
after the schema deploy they depend on.
## Consequences
- `wdg_invoices_overdue_sum` 101.9 s → 0.45 s, `wdg_unbez_akonto` 4.7 s → 0.4 s, `wgd_lastEmails`
error → 15 ms (dev, 2026-09-16). The widget command timeout stays at 90 s; it is a guard, not a
budget.
- Adding a scalar UDF call inside a widget's `SELECT`/`WHERE` over `fds__invoices` or the
`fds__fn_unpaidInvoices()` set is a regression of this decision. Prefer exposing a set-based column
on the TVF, as done for `PaymentAmount`.
- `ProcessWebComServiceTests` assert the config contract on every audit path; changing the shape
requires updating `fds_wgd_lastEmails` in the same change.
- The `Scripts/2026-09-16_fis_widgets_set_based_payment_amounts.sql` migration must be run on
Production after the schema deploy (it is a no-op once applied).
## Alternatives considered
- **Raise the widget `CommandTimeout`** — masks an O(N) re-scan; the cost grows with every unpaid
invoice and every banking upload, and it would still block a dashboard load for minutes.
- **Convert `fds__fn_InvoicePaymentAmount` into an inlineable scalar UDF** — impossible while it
needs a TVP (`fds__tt__idlist_vchar`) to call the banking TVF; rewriting the banking TVF as an
inline TVF is a larger change to a function shared with the banking-assignment UI.
- **Fix the bad `fds__emaillog` row and leave the widget function strict** — the next `''` row (any
writer, any environment) would take the widget down again; tolerance belongs in the reader.
@@ -0,0 +1,114 @@
---
status: Accepted
date: 2026-09-17
applyTo:
- "Fuchs/Controllers/IntranetController.Admin.cs"
- "Fuchs/Services/IInvoiceFileBackupService.cs"
- "Fuchs/Services/InvoiceFileBackupService.cs"
- "Fuchs/Services/IBlobStorageService.cs"
- "Fuchs/Services/AzureBlobStorageService.cs"
- "Fuchs_Database/dbo/Stored Procedures/fds__admin_backupAndClearInvoiceFile.sql"
- "Fuchs_Database/dbo/Tables/fds__invoice_file_backups.sql"
supersededBy: ""
---
# 0027 — Admin invoice-file backup-and-clear action
## Context
Support occasionally needs to force a finalised invoice's PDF to be regenerated
(e.g. it was rendered with wrong data before a fix, or is otherwise corrupt).
Since [0025](0025-invoice-document-regenerate-on-miss.md), clearing
`fds__invoices.[file]` on a finalised, non-external invoice is enough to make
the next `req/idoc`/`inv/rdoc` read regenerate and re-persist it.
A manual maintenance proc for this already exists,
`[dbo].[_BackupAndClearInvoiceFile]`, but its own header comment states it is
"Manual-only maintenance utility. Never called from application code", and
0025's "Alternatives considered" explicitly rejected relying on it from the
app, partly because it leaves no application log entry. We still want this
capability reachable from the app (a hidden, admin-gated action) rather than
requiring direct SSMS access, without contradicting that standing decision.
Additionally, `IBlobStorageService` archives invoice PDFs to Azure Blob
Storage under a fixed per-invoice blob name (`{invoiceId}/{fileName}`) and
always uploads with `overwrite: true`. If a cleared invoice's document is
later regenerated (0025), `StoreInvoiceDocumentFileAsync` re-uploads under
that same blob name — silently overwriting the very copy this action is
meant to preserve as a backup, unless it is moved first.
## Decision
- A **new** proc, `[dbo].[fds__admin_backupAndClearInvoiceFile]`, is added
rather than calling `_BackupAndClearInvoiceFile`. It copies the invoice's
current `[file]` bytes into a new table, `[dbo].[fds__invoice_file_backups]`
(one row per backup, not a timestamp-named ad-hoc table per call), then
clears `[file]` in the same transaction (`XACT_ABORT` + TRY/CATCH with
rollback and re-raise, so a clear can never commit without its backup row).
It logs through the normal
`fds__admin_activity` insert (matching `fds__setInvoiceFile`'s pattern) and
every call is also logged through the app's `ILogger`/DB-log-sink pipeline
(ADR 0024) — unlike the manual proc, this path is fully auditable.
`_BackupAndClearInvoiceFile` is left completely untouched as the manual
break-glass tool for cases the app can't reach (e.g. a corrupt row the
admin UI can't load).
- The action applies 0025's regeneration precondition **before** touching
anything: only a **finalised, non-external** invoice qualifies. Both the
service (`IsDraft`/`IsExternal`) and the proc (`[DateFinalized] IS NOT NULL
AND ISNULL([IsExternal],0) = 0`) enforce it. Clearing a draft's `[file]`
would let the next read persist a draft render through
`fds__setInvoiceFile` (`WHERE [file] IS NULL`), which then blocks the real
final PDF forever — the exact defect 0025 calls load-bearing; an external
(MFR) document has no template and would just become a permanent 404.
- `IBlobStorageService` gains `CopyInvoicePdfToBackupAsync`: a
**server-side copy** (`StartCopyFromUriAsync`, bytes never pass through
the app) of the existing blob to a timestamped backup name in the same
container — the original blob is **kept**, not renamed. A private source in
the same storage account is authorised by the request's own Shared Key
credential, so no SAS is generated. The copy runs *before* the DB clear,
so the archived original is safe before any regenerate-on-read (ADR 0025)
could overwrite the original name; because the original stays in place, a
DB error or no-op afterwards leaves the canonical archive exactly as it
was (with a harmless spare copy). Keeping the original also removes the
half-state a copy-then-delete would have ("copied but couldn't delete").
Best-effort: a copy failure is logged and counted
(`fuchs.blobstorage.backupcopies.failed`), the DB backup row stays the
authoritative copy, and the DB step still runs.
- The action is exposed as a `Do_Process_Admin` case,
`admin/backupclearinvoicefile/<invoiceId>`, gated by the existing
`fds_sys > 4` check and restricted to `POST` (a GET must never trigger it,
since GETs can be issued by crawlers/prefetchers without user intent). It
is not part of any regular workflow UI; its only frontend is a card on the
admin-only "Specialties" page (`/specialties`, see
`Docs/Concepts/admin-specialties.md`).
## Consequences
- Two invoice-file "backup and clear" code paths now exist for different
audiences: `_BackupAndClearInvoiceFile` (manual, SSMS, no app log) and
`fds__admin_backupAndClearInvoiceFile` (app-triggered, fully logged). Keep
them separate — do not merge them, and do not point the manual proc's
printed recovery links at the new admin action.
- `fds__invoice_file_backups` accumulates one row per backup with no
automatic retention/cleanup; if this ever becomes a storage concern, that
is a separate decision.
- Blob backup-copy is best-effort: if it fails (or storage is
disabled/unconfigured), the invoice's DB row is still backed up and
cleared — the archived blob is simply left as-is (and will be overwritten
by the next regeneration). The DB backup table is the only *guaranteed*
copy this action makes.
- The blob container accumulates one `backup_<timestamp>_…` copy per action
next to the current blob; like `fds__invoice_file_backups`, no retention
is applied.
## Alternatives considered
- **Call `_BackupAndClearInvoiceFile` directly from the app.** Rejected: its
own header comment and ADR 0025 both state it must never be
application-triggered, and it produces no application log entry — using it
here would need updating that comment and (since decisions are immutable)
superseding 0025's "Alternatives considered" note, for no real benefit over
a small dedicated proc.
- **Skip the blob rename and let the next regeneration overwrite the archived
blob.** Rejected: the user asked for the blob copy to also survive as a
backup, and a silent overwrite on next read would defeat that.
@@ -0,0 +1,91 @@
---
status: Accepted
date: 2026-09-17
applyTo:
- "Fuchs/Services/IComService.cs"
- "Fuchs/Services/ProcessWebComService.cs"
- "Fuchs/Services/ProcessWebComSettings.cs"
- "Fuchs/Services/MailerAttachmentPolicy.cs"
supersededBy: ""
---
# 0028 — Mailer push_com: multipart attachments, count confirmation, contract pre-validation
## Context
The ProcessWeb Mailer API (`push_com`) was extended to accept attachments.
Its contract — https://processweb.de/api/mailer/fc — specifies
`multipart/form-data` with the JSON envelope in an unnamed form field and one
file part per attachment, an optional `attachmentCount` safety check, and a
200 response reporting `attachmentsReceived`/`attachmentsProcessed`.
`ProcessWebComService` predated that: it base64-inlined attachments as
`communication.attachments[]` in a JSON body. That property is not part of
the contract, so the Mailer accepted the message and dropped the files — a
200 for an invoice mail whose PDF never arrived, with no error anywhere. The
client also fabricated the audit `guid` (`Guid.NewGuid()`), treated a 409
(idempotency duplicate) as a generic API error, and called the legacy
`/mailer/fc?fn=verify` and `api.processweb.de` paths.
## Decision
1. **Request shape follows the contract exactly.** No attachments →
`application/json` envelope. With attachments → `multipart/form-data`:
envelope in an unnamed field (`name=""`, set via an explicit
`Content-Disposition` because `MultipartFormDataContent.Add` rejects an
empty name), then one file part per attachment with real bytes. Field
names as in the contract (`comType`, not `comtype`). Base URL
`https://processweb.de`, paths `/api/mailer/push_com` and
`/api/mailer/verify`.
2. **`attachmentCount` is always sent** so the Mailer itself rejects a
request whose file parts don't match.
3. **A 200 is not success when attachments were sent unless
`attachmentsProcessed` equals the number sent.** Anything else — fewer
processed, or a body that isn't the contract JSON — is reported to the
caller as a failed send (`false`, reason `attachments-mismatch`, counts
in the audit log). Callers gate "mark as sent" on that boolean, so an
invoice whose PDF was not processed is never flagged as sent.
4. **Contract limits are pre-validated client-side** (`MailerAttachmentPolicy`:
count, per-file and per-message size, empty file, missing/forbidden
extension) with the contract's defaults configurable under
`Fuchs:Mailer:Attachments`. A violation fails before any bytes are sent,
with the exact rule in the audit log. Empty files are a violation, not
silently skipped as before.
5. **`comStackID` is the audit key**, falling back to the `Message-Id`
header; a local id only when the Mailer returned neither.
6. **409 is a distinct failure** (`duplicate`): the Mailer already holds a
message under this `reference` and did not queue this one.
## Consequences
- `IComService.SendEmailAsync` returning `true` now means "accepted *and*
every attachment confirmed processed" — strictly stronger than before.
Existing callers (`req/sconf`, reminders, admin test mail) need no change;
they already treat `false` as not-sent.
- A caller passing a 0-byte attachment now gets `false` instead of a mail
without the file. That is intended: an empty PDF is an upstream failure
that should surface, not be hidden by a successful-looking send.
- The forbidden-extension list in the client is the contract's quoted subset;
the server's longer list stays authoritative and still answers 400 for the
rest.
- The concept doc `Docs/Concepts/processweb-mailer-interface.md` describes
the current client behaviour and must be kept in sync when the contract
evolves (it is declared backward compatible, so additions rather than
breaks are expected).
- The verify response is still parsed as `{ ok, account, serverid }`; if the
`/api/mailer/verify` endpoint's body differs from the legacy `fc?fn=verify`
one, only `VerifyCredentialsAsync`'s DTO needs adjusting.
## Alternatives considered
- **Keep inline base64 and ask the Mailer to accept it too.** Rejected: the
contract is the Mailer's; two accepted shapes would just be a second
thing to keep compatible, and base64 inflates every PDF by a third.
- **Treat 200 as success regardless of `attachmentsProcessed`.** Rejected —
that is exactly the silent-drop failure mode this change exists to close.
- **Treat 409 as success ("already sent").** Rejected: a resend under the
same reference would then report success without anything being queued;
the caller must see it and decide (e.g. use a new reference).
- **Skip client-side validation and rely on the server's 400.** Rejected:
the 400 body is an opaque string in the audit log; a named rule is what
support needs, and it costs nothing.
@@ -0,0 +1,101 @@
---
status: Accepted
date: 2026-09-17
applyTo:
- "Fuchs/Services/ProcessWebComService.cs"
- "Fuchs/Services/FuchsMailerClient.cs"
- "Fuchs/Services/MailerAttachmentPolicy.cs"
- "Fuchs/Services/ProcessWebComSettings.cs"
- "Fuchs/Program.cs"
supersededBy: ""
---
# 0029 — Outbound mail goes through the OCORE_web PWMailer client
## Context
Right after [0028](0028-mailer-push-com-multipart-and-attachment-confirmation.md)
re-implemented the Mailer API's `push_com` contract by hand in
`ProcessWebComService`, OCORE_web gained a complete typed client for that
API (`OCORE_web/comm/pwmailer/`, `IPWMailerClientService`): every operation,
the JSON/multipart request shapes, `attachmentCount`, typed receipts,
Basic auth with Key Vault/encrypted token resolution, `x-pcw-excd` error
headers, retries and timeouts — declared to be kept in sync with the
server-side controllers. Keeping a second implementation of the same
contract in Fuchs would mean two things to update on every API change.
The client validated a message's required fields and empty/extension-less
attachments, but had no hook for application rules (size limits, a
file-type deny-list) — `PWMailerMessage` is sealed and `PushAsync` called
`Validate` directly.
## Decision
1. **`ProcessWebComService` is an adapter over `IPWMailerClientService`.**
It keeps only what is Fuchs-specific: recipient validation and the
`Fuchs:Email:OverrideRecipient` safety net, the signature, building the
`PWMailerMessage`, interpreting the `PWMailerResult`, the `fds__emaillog`
audit row and telemetry. All HTTP transport lives in the OCORE client;
the hand-rolled `PostPushComAsync`/MIME guessing from 0028 is deleted.
2. **Pre-validation is an overridable hook in the client.** OCORE_web's
`PWMailerClientService` gains `protected virtual ValidateMessage(PWMailerMessage)`
(default: `PWMailerMessage.Validate`), called by `PushAsync` and
`PushBulkAsync` before anything is sent. Fuchs overrides it in
`FuchsMailerClient` to add the contract's attachment limits
(`MailerAttachmentPolicy`: count, per-file/per-message size, forbidden
extensions, configurable under `Fuchs:Mailer:Attachments`). A refusal is a
`PWMailerResult` with `StatusCode 0` / `Attempts 0`, which the adapter
reports as reason `validation` with one audit line per rule.
3. **One configuration section.** `Fuchs:Mailer` (`ProcessWebComSettings`)
remains the source; `Program.cs` maps it onto `PWMailerClientOptions`
(`BaseUrl + /api/mailer`, `AccountId → Account`, `Token`, `ServerId`) and
sets `TokenSecretName = "Fuchs--Mailer--Token"`, i.e. the Key Vault secret
OCORE secret management already maps into `Fuchs:Mailer:Token`. No second
`PWMailer` section, no second secret.
4. **0028's behavioural rules stand**, with one correction: multipart with
`attachmentCount`, a 200 counts as success only when
`attachmentsProcessed` equals the number sent, `comStackID` is the audit
key. **409 is now a success, not a `duplicate` failure** (this replaces
0028's rule 6): the client documents 409 as "duplicate of a still-pending
entry with the same server/reference/content — nothing was enqueued, the
earlier entry will be sent". The mail is therefore on its way; the resend
paths (`req/resend`, reminders) reuse `inv_<Id>` as the reference, and a
double click must not surface as a send error. The deduplication is
recorded in the audit row. They are enforced partly by the client (shape,
count) and partly by the adapter (processed check, audit, 409 mapping).
5. Two small OCORE_web fixes made in the same change: `PWMailerMessage.Validate`
no longer demands a subject for `comType "s"` (SMS), and a call refused
by validation reports `Attempts = 0` as its documentation already stated.
## Consequences
- Fuchs no longer asserts the wire format in its own tests; that lives in
`OCORE_web/OCORE_webTests/comm/PWMailerClientServiceTests.cs`. Fuchs tests
assert the `PWMailerMessage` handed to the client and the interpretation of
each `PWMailerResult` shape, plus `FuchsMailerClient`'s refusals.
- OCORE_web is a submodule owned by another repository: the hook, the SMS
rule and the `Attempts` fix are a commit there and a pointer bump here.
- The client is a singleton with a cached token; after a token rotation
`IPWMailerClientService.ResetToken()` (on the concrete type) is the way to
pick it up without a restart — not wired to any admin action yet.
- Empty attachments are still refused (now by the client's base rule), so
an upstream render failure never produces a mail without its PDF. The
flip side: a caller attaching an *optional* file must skip an empty byte
array itself — the reminder's invoice copy (`IntranetController.Reminder.cs`)
does, otherwise a never-stored invoice file would fail the whole reminder
instead of sending it without the copy.
- The client's `getCom`/`getComStatus`/`abortCom` and inbox operations are
available to Fuchs for free (e.g. a delivery-status column keyed by the
audited `comStackID`) — not used yet.
## Alternatives considered
- **Keep the 0028 hand-rolled transport.** Rejected: duplicate contract
implementation, and the OCORE client is the one declared to track the
server.
- **Pre-validate in the adapter only (before `PushAsync`).** Rejected: the
rules belong with the client's own validation so every OCORE consumer can
apply them the same way, and the user asked for an overridable hook.
- **Bind a separate `PWMailer` config section.** Rejected: `Fuchs:Mailer` is
already Key Vault-managed and read by the Admin status view; mapping in
code avoids a second token secret and drift between two sections.
@@ -0,0 +1,69 @@
---
status: Accepted
date: 2026-09-17
applyTo:
- "Fuchs_Database/dbo/Stored Procedures/fds__admin_backupAndClearInvoiceFile.sql"
- "Fuchs_Database/dbo/Tables/fds__invoice_file_backups.sql"
supersededBy: ""
---
# 0030 — Invoice-file backup carries a JSON snapshot of the invoice data
## Context
ADR 0027 introduced `[dbo].[fds__invoice_file_backups]`: one row per
admin backup-and-clear action holding the invoice's `[file]` bytes before
they are cleared for regeneration (ADR 0025). The backup held only the
rendered PDF. The data it was rendered from — the `fds__invoices` row, its
`fds__invoice_items` rows, its `fds__invoice_details` row and its
`fds__invoice_servicerequests` link rows — lives on in
the live tables and keeps changing (payment status, modification stamps,
later edits). A backed-up file could therefore no longer be matched against
the data state it was produced from, which is exactly the question the
backup exists to answer when a regenerated document differs from the
original.
## Decision
- Each backup row additionally stores four JSON snapshots, written in the
same statement and transaction as the file copy:
- `InvoiceJson` — the `fds__invoices` row (`FOR JSON PATH,
WITHOUT_ARRAY_WRAPPER`), **without** `[file]`, which is already stored as
bytes in the row and would otherwise be base64-duplicated;
- `InvoiceItemsJson` — all `fds__invoice_items` rows for the invoice as a
JSON array, ordered by `SortOrder`, `Position`, `Id`;
- `InvoiceDetailsJson` — the `fds__invoice_details` row, or `NULL` if the
invoice has none;
- `InvoiceServiceRequestsJson` — all `fds__invoice_servicerequests` rows
for the invoice as a JSON array, ordered by `SortOrder`, `Id`. Not part
of the rendered document, but part of the invoice's data set.
- The snapshots are JSON columns on the **same** backup row rather than
four mirror tables: one row is one consistent set (file + data), the
atomicity guarantee of ADR 0027 covers it without extra keys, and adding a
column to an invoice table does not require touching the backup schema.
The item/detail/service-request snapshots use `SELECT *`, so new columns appear
automatically; the invoice snapshot lists its columns explicitly only to
exclude `[file]` — a new `fds__invoices` column must be added to that
list.
- Restore is manual (`OPENJSON` in SSMS); no app-side reader is added. The
snapshots are forensic evidence, not a rollback mechanism.
## Consequences
- Backup rows grow by the size of the invoice's data (small next to the
PDF). No retention policy is added — same standing as ADR 0027.
- `INCLUDE_NULL_VALUES` is used so a `NULL` in the snapshot is
distinguishable from a column that did not exist at backup time.
- Adding a column to `fds__invoices` requires updating the explicit column
list in the proc; `fds__invoice_items` / `fds__invoice_details` /
`fds__invoice_servicerequests` need no change.
## Alternatives considered
- **Mirror tables** (`fds__invoice_backups`, `fds__invoice_items_backups`,
…) keyed by backup `uid`: typed and queryable, but four schemas that
drift with every invoice-table change and four inserts to keep atomic.
Rejected for maintenance cost; the snapshots are read rarely and by hand.
- **Snapshot only the invoice header**: the items are what the PDF body is
rendered from; leaving them out would not answer "what did this file
contain and why".
@@ -0,0 +1,76 @@
---
status: Accepted
date: 2026-09-09
applyTo:
- "OCORE/OCORE/Secrets/**"
- "OCORE_web/OCORE_web/Secrets/**"
- "Fuchs/Program.cs"
- "Fuchs/appsettings*.json"
supersededBy: ""
---
# 0031 — Shared Key Vault + DPAPI secret management via OCORE/OCORE_web
## Context
Fuchs is one of several applications (web apps, console apps, Windows
services, worker services) running on the same Windows server. Secrets
(connection strings, API keys, mailer tokens, etc.) previously lived in
plain text in `appsettings.json`/`web.config`/environment variables. A
single design needed to work identically across all host types — not just
ASP.NET Core — while keeping secrets out of source control and available
even when the Key Vault is briefly unreachable.
## Decision
- **One shared Azure Key Vault** (`pcwkeys`) for all applications on the
server, rather than a per-app vault. Access control happens at the
secret level (RBAC), not at the vault level.
- **`{appname}--` prefix namespacing** on every secret name (e.g.
`fuchs--ConnectionStrings--ocms-ConnectionString`) prevents collisions
between apps sharing the vault. Naming/encoding rules are recorded in
`.github/copilot-instructions.md` under "Azure Key Vault — Secret
Naming".
- **Core logic lives in `OCORE`** (`OCORE/Secrets/Models/AppSecretsOptions.cs`,
`Cache/DpapiSecretsCache.cs`, `Cache/DpapiCacheConfigurationSource.cs`,
`Sync/KeyVaultSyncService.cs`, `Extensions/SecretManagementExtensions.cs`,
`Extensions/PrefixKeyVaultSecretManager.cs`, `Extensions/SecretsOnDemandLoader.cs`)
because it has no web dependency and must also work for console apps,
Windows services, worker services, and WinForms/WPF. `OCORE_web` adds a
single thin file (`Secrets/SecretManagementWebExtensions.cs`) exposing
`WebApplicationBuilder.AddSecretManagement()` for ASP.NET Core hosts;
it calls back into `OCORE`'s shared logic rather than duplicating it.
- **Local DPAPI cache as offline fallback**: secrets synced from Key Vault
are also encrypted to `secrets.cache` using Windows DPAPI with
`DataProtectionScope.LocalMachine` plus app-specific entropy derived
from `AppName`, so one app on the server cannot decrypt another app's
cache file, and the app can still start if Key Vault is briefly
unreachable.
- **One shared server-level Managed Identity** (via `DefaultAzureCredential`)
for all apps, rather than 15 separate identities — access is scoped by
the `{appname}--` secret prefix via Key Vault RBAC, not by identity.
## Consequences
- Fuchs calls `builder.AddSecretManagement()` (`Fuchs/Program.cs`) before
`FuchsOcmsIntranet.Initialize(configuration)` — secrets must be resolved
before any singleton reads `IConfiguration`.
- New secrets must be added to Key Vault under the `fuchs--` prefix *and*
registered in `ManagedSecretKeys` in `Fuchs/appsettings.json` (see
`.github/copilot-instructions.md`), or `KeyVaultSyncService` will never
pick them up.
- A secret whose name is only known at runtime (not a fixed config key)
bypasses `ManagedSecretKeys`/`KeyVaultSyncService` entirely and is
fetched directly via `OCORE.Secrets.Extensions.SecretsOnDemandLoader`
instead.
- Any other application added to the shared server follows the same
pattern: reference `OCORE_web` (web) or `OCORE` (non-web), set a unique
`AppName`, and grant its prefix `Get`/`List` access in the shared vault.
- Detailed wiring/encoding reference (kept current independently of this
ADR) lives in `OCORE_web/.github_instructions/secretsmanagement.instructions.md`.
## Alternatives considered
- **Per-app Key Vaults** — rejected: 15x the management overhead for no
isolation benefit over prefix namespacing + secret-level RBAC.
- **Per-app Managed Identities** — rejected: requires provisioning and
maintaining 15 separate identities; a shared identity with per-secret
RBAC achieves the same isolation.
- **Environment variables only, no local cache** — rejected: no
offline/startup resilience if Key Vault is briefly unreachable.
@@ -0,0 +1,68 @@
---
status: Accepted
date: 2026-09-30
applyTo:
- "Fuchs_Database/dbo/Functions/fds__fn_invoiceBackdate.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__setInvoiceFinal.sql"
- "Fuchs/code/InvoiceDates.cs"
- "Fuchs/Services/ERechnungMapper.cs"
- "Fuchs/Services/InvoiceService.cs"
- "Fuchs/Controllers/IntranetController.InvoiceDraft.cs"
- "Fuchs/Controllers/IntranetController.Requests.cs"
- "Fuchs/js/intranet/modules/fis.inv_shared.js"
supersededBy: ""
---
# 0032 — Invoices may be backdated to the last working day of the previous month (admins only)
## Context
Work done at the end of a month is often invoiced in the first days of the next month. The customer
wants those invoices dated to the previous month. The invoice date was always the moment of
finalisation (`fds__setInvoiceFinal`: `DateFinalized = GETUTCDATE()`), and the invoice number is
drawn from the year of that date (`fds__newInvoiceId(Year(...))`).
Two invoice dates also existed side by side: the PDF printed `DateCreated` (draft creation) as
"Rechnungsdatum", while the embedded ZUGFeRD/XRechnung issue date (BT-2) used `DateFinalized` — a
visible date and an e-invoice date that could disagree.
## Decision
- **One invoice date.** PDF "Rechnungsdatum" and e-invoice issue date both come from
`InvoiceDates.IssueDate`: `DateFinalized` (converted from the stored UTC to the German calendar
day), falling back to `DateCreated` for drafts.
- **Backdating option.** When finalising, a user with `fds_inv > 2` may date the invoice to the
**last working day (Mon–Fri) of the previous month** — a fixed date, not a free choice. Public
holidays are not excluded (possible later extension).
- **Only while no invoice exists after that date.** Invoice numbers must ascend with invoice dates,
so the option is offered and accepted only while no invoice has been finalized after the backdate
day (a 2 h margin covers the UTC storage).
- **Single source of truth:** `[dbo].[fds__fn_invoiceBackdate](@authuser)` returns the allowed date or
NULL (authorisation + eligibility). `inv/dpreview` returns it so the preview dialog shows the
per-invoice checkbox; `req/sconf` passes `@backdate = 1`, and `fds__setInvoiceFinal` re-evaluates
the function and `THROW`s (60001) when it is no longer allowed — nothing is finalised then.
- **Serialized finalisation.** `fds__setInvoiceFinal` runs check + numbering `UPDATE` in one
transaction under an exclusive, transaction-owned app lock (`sp_getapplock`, resource
`fds__setInvoiceFinal`) taken by every finalisation, so a concurrent normal finalisation cannot
slip between the eligibility check and the backdated write and break the date/number order.
- `req/sconf` maps only SQL error 60001 to the "no longer possible, create without backdating"
message. A 0-row result (invoice already final/sent, e.g. a double submit) is reported as
"already finalised" — never as a backdate rejection, which would invite a duplicate invoice.
- A backdated invoice is stored with `DateFinalized = <day> 12:00` (UTC, stays on the same German
day), its number comes from that day's year, and `DueDate` is recomputed from the payment term
anchored on that day.
## Consequences
- The PDF of every newly finalised invoice now shows the finalisation date instead of the draft
creation date (intended: it now matches the e-invoice and the number sequence).
- In January the option yields a December date and therefore a number from the previous year's range.
- Lists, reports, DATEV and reminders follow the same invoice date — see ADR 0035.
- Telemetry: `fuchs.invoices.backdated` (outcome ok / rejected / already_final / error); successful
backdating logs EventId `InvoiceBackdated`.
## Alternatives considered
- **Free date picker for admins** — rejected: breaks the date/number order too easily; the need is
exactly "previous month's last working day".
- **Global admin switch "backdate everything until the first invoice of the month"** — rejected by
the customer in favour of an explicit per-invoice checkbox.
@@ -0,0 +1,52 @@
---
status: Accepted
date: 2026-09-30
applyTo:
- "Fuchs_Database/dbo/Tables/mfr__*.sql"
- "Fuchs_Database/dbo/User Defined Types/mfr__tt__*.sql"
- "Fuchs_Database/dbo/Stored Procedures/mfr__updt__*.sql"
- "Fuchs_DataService/FdsMfrClient.cs"
- "Fuchs_DataService/FdsColumnWidths.cs"
supersededBy: ""
---
# 0033 — mfr free-text columns are NVARCHAR(MAX); table, table type and copies stay aligned
## Context
mfr publishes no length limits for its string properties (`MFR_RESTClient/Docs/mfr_interface_description.md`).
`[mfr__serviceobjects].[Note]` and `[mfr__companies].[Note]` were `NVARCHAR(255)`, as were their
merge types `mfr__tt__serviceobjects` / `mfr__tt__companies`. From 2026-09-25, users typed longer
notes in mfr (ServiceObject up to 753 chars, Company up to 723). The `mfr__updt__*` merge proc copies
the staging table into the table type, so SQL Server rejected every batch holding such a row
(msg 8152, which names no column). That included every Company/Contact/ServiceRequest sync that
`$expand`s the row. The result was about 1000 errors in five days, plus rows the short sync never
re-fetched, because it only reads rows newer than the local `DateModified` watermark. Merge failures
were silent until 8bcec40 began logging them, so earlier occurrences went unnoticed.
## Decision
- Free-text mfr properties (`Note`, `Description`, `Comment`, …) are stored as `NVARCHAR(MAX)`.
Bounded widths are kept only for values mfr structurally keeps short (ids, codes, names), and
those are revisited when data proves otherwise.
- A width change is made in the target table, its `mfr__tt__*` type **and** every table variable
or type that copies the row with `SELECT *` (e.g. `@company` in `fds__getRequest_details`,
`fds__prepInvoice`, `fds__prepStorno_recreate`), all in the same change. Otherwise the truncation
just moves to the next copy.
- Truncating at the mapping layer was rejected. The data service does **not** truncate
silently. Instead, when a merge fails with 8152/2628, `FdsColumnWidths` compares the staged
batch against the narrower of the table and type widths. The error log then names column, longest
length, limit and an entity Id (`over-long columns: Note: 753 > 255 chars (1 row, e.g. Id=…)`).
This lookup runs only on the failure path.
## Consequences
- Changing a `mfr__tt__*` type means DROP + CREATE (types cannot be ALTERed). The procs that
DECLARE the type must be dropped first and recreated. See
`Fuchs_Database/Scripts/2026-09-30_mfr_truncation_fix.sql` for the pattern, including grants.
- After fixing a rejected merge, queue the affected Ids in `fds__mfr_updaterequests`. The short
sync will not revisit them, and the nightly full reset is disabled in `fds__getUpdateableTables`.
- `NVARCHAR(MAX)` columns cannot be index key columns. None of the widened columns is indexed.
## Alternatives considered
- **Truncate to the column width in `SetRowValue`**: this would keep syncing, but it loses user text
silently. For keys such as `ExternalId` (DATEV customer number) it would corrupt data. Rejected.
- **Widen to a larger fixed size (e.g. 1000, like `mfr__contacts.Note`)**: this just moves the
cliff. mfr enforces no limit. Rejected for free text.
@@ -0,0 +1,64 @@
---
status: Accepted
date: 2026-09-30
applyTo:
- "Fuchs_Database/dbo/Functions/fds__fn_IntermediateIsAllocatedToOther.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__setInvoiceFinal.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__prepInvoice.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__prepStorno_recreate.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__createStorno_*.sql"
- "Fuchs_Database/Scripts/2026-09-30_backfill_intermediate_invoice_allocations.sql"
supersededBy: ""
---
# 0034 — An Abschlagsrechnung credit follows the live invoice
## Context
A Schlussrechnung credits the Abschlagsrechnungen (intermediate invoices, `InvoiceType = 'i'`)
already paid for the same service requests via `IntInvoiceAllocation` lines. Finalising it records
the claim in `fds__invoice_details.AllocatedTo_InvId` of each credited intermediate so that
`fds__prepInvoice` does not offer the same Abschlagsrechnung again.
Until 2026-09-30 that claim was never actually written (the editor dropped `reference_InvId`, the
MERGE aborted on the NULL). The moment it is written, the question "what happens to the claim when
the crediting invoice is cancelled?" becomes real: a Storno (simple/copy) or a Storno + recreate
leaves `AllocatedTo_InvId` pointing at the cancelled invoice, and the next invoice for the same
service requests would silently bill the customer again for money already paid.
## Decision
- **A claim counts only while the crediting invoice is alive.** An `AllocatedTo_InvId` that points
at a cancelled invoice (`IsCanceled = 1`), at a non-existent invoice, or at the invoice that the
one being finalised replaces (`CanceledInvoice` line → `@Replaces`) is void: the Abschlagsrechnung
is offered again by `fds__prepInvoice` / `fds__prepStorno_recreate` and may be taken over by the
next finalisation. The single source of truth for "already credited elsewhere" is
`[dbo].[fds__fn_IntermediateIsAllocatedToOther]`; `fds__setInvoiceFinal`'s `existing` CTE is its
set-based twin and must apply the same rule.
- **The link is not cleared on cancellation.** Cancellation has several paths (storno simple/copy,
storno + recreate, the retired `fds__setInvoiceCancelled`); rather than releasing the link in each,
the readers ignore dead links. The last claimant therefore stays visible for auditing.
- **A Stornorechnung (`InvoiceType = 'c'`) never claims.** `fds__createStorno_copy` mirrors the
cancelled invoice's `IntInvoiceAllocation` lines (with their references) so the storno document
shows them, but finalising the storno must not move the allocation onto it.
- **Storno + recreate carries the references.** `fds__prepStorno_recreate` emits its credit lines
with the same type as `fds__prepInvoice` (`IntInvoiceAllocation`, not `PartialInvoice`) and the
credited invoice in `reference_InvId`, and its `CanceledInvoice` line references the replaced
invoice — otherwise the replacement neither takes over the credit nor records what it replaces.
## Consequences
- The recreate flow now produces `Replaces_InvId` / `DateCancelled` on the replaced invoice via the
regular `fds__setInvoiceFinal` bookkeeping (it did not before: no reference reached the proc).
- A second recreate (cancel the replacement, recreate again) keeps the credit because the claim on
the replaced invoice is treated as void during the replacement's finalisation.
- The one-off backfill script records the *earliest* live claimant; it is consistent with this rule.
- An intermediate that is itself cancelled is not offered (unchanged).
## Alternatives considered
- **Release `AllocatedTo_InvId` when the crediting invoice is cancelled** — rejected: four write
paths to keep in sync, and the audit trail of who last credited the Abschlagsrechnung is lost.
- **Keep `PartialInvoice` for recreated credit lines and teach `fds__setInvoiceFinal` both types** —
rejected: one line type per meaning; `PartialInvoice` remains the Abschlagsbetrag charge line of an
intermediate invoice.
@@ -0,0 +1,63 @@
---
status: Accepted
date: 2026-09-30
applyTo:
- "Fuchs_Database/dbo/Functions/fds__fn_invoiceDate.sql"
- "Fuchs_Database/dbo/Functions/fds__r_getBalanceThisMonth.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__getDatevExports.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__getInvoices_list*.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__getFDSDocument.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__getReportDocument.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__getRequest_details.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__r_*.sql"
- "Fuchs_Database/dbo/Stored Procedures/fds__*Reminder*.sql"
- "Fuchs/code/InvoiceDates.cs"
- "Fuchs/Services/ReminderService.cs"
- "Fuchs/Services/*DraftEditService.cs"
supersededBy: ""
---
# 0035 — The invoice date is `DateFinalized` everywhere
## Context
ADR 0032 made `DateFinalized` the invoice date for the PDF, the e-invoice (BT-2) and the number
sequence, and introduced backdating (which only moves `DateFinalized`). Everything else — invoice
lists, revenue reports and charts, the DATEV export, document lookups, the request details — still
dated and grouped invoices by `DateCreated`, the moment the *draft* was created. A backdated invoice
therefore showed September on the document and October in every list, report and in DATEV, and any
invoice drafted in one month and finalised in the next had the same split.
## Decision
- **One invoice date, everywhere:** the German calendar day of `DateFinalized`, falling back to
`DateCreated` only for drafts. Both columns are stored in UTC.
- **One implementation per side:** `[dbo].[fds__fn_invoiceDate](DateFinalized, DateCreated)` in SQL and
`InvoiceDates.IssueDate` in C#. No proc converts or picks the date itself.
- Applies to: the DATEV export (period filter, `Belegdatum`, `Belegfeld 2`), the invoice lists
(`fds__getInvoices_list`, `list2`, `list_vario`: period filter, `Belegdatum`, `DateOfCreation`,
ordering, overdue fallback), the revenue reports (`fds__r_*`: year/month filters and grouping,
`fds__r_getBalanceThisMonth`), document lookups (`fds__getFDSDocument`, `fds__getReportDocument`),
`fds__getRequest_details` (new column `InvoiceDate`, shown in the request's invoice list), and the
invoice date shown in reminders (`fds__prepReminder`, `fds__createReminder`, `fds__getReminder`).
- **Reminders use the same rule for their own date:** the reminder PDF's date is the reminder's
`DateFinalized` (German day), draft: `DateCreated`.
- **No transition safeguard.** The switch applies to all periods, past and future; already exported
DATEV periods are not reconciled (confirmed by the business owner, 2026-09-30).
- `DateCreated` keeps its meaning — the creation timestamp of the row — and is still returned where
callers read it as such. Draft previews stamp it with `DateTime.UtcNow` to match the stored column.
## Consequences
- Reports and exports for a past period can differ from what they showed before this change: an
invoice drafted in month M and finalised in M+1 now counts in M+1.
- Filters on the invoice date are not SARGable (they were not before either: `CAST(DateCreated as date)`).
Acceptable at the current table size; an indexed computed column is the follow-up if it matters.
- Any new list, report or export that shows or groups invoices by date must use
`fds__fn_invoiceDate` — never `DateCreated` or a raw `DateFinalized`.
## Alternatives considered
- **Keep `DateCreated` in lists/reports, `DateFinalized` only on documents** — rejected: the same
invoice would carry two dates, and backdating would never reach the books.
- **Transition safeguard for already exported DATEV periods** — rejected by the business owner.
@@ -0,0 +1,85 @@
---
status: Accepted
date: 2026-09-30
applyTo:
- "Fuchs_Database/**"
supersededBy: ""
---
# 0036 — The database project build is the T-SQL gate
## Context
A rewrite of `fds__getDatevExports` added a `JOIN @inv as i` next to `fds__invoices as iv`
and left an unqualified `[file_guid]` in the select list. SQL Server rejects that statement
with Msg 209 (ambiguous column name) — but only when it runs, and the working-tree SQL runs
nowhere: the schema lives in the `Fuchs_Database` SSDT project, the dev database only gets
what is explicitly published to it, and the xUnit suite cannot bind T-SQL. The defect reached
code review as the only check.
The SSDT build would have caught it: it reported the line as `SQL71502` ("unresolved reference
… or ambiguous"). Nobody saw that because
- the project did not build at all — the two `CREATE USER … FOR LOGIN` files referenced logins
that were not in the model, which SSDT treats as error `SQL71501`;
- the genuine warning sat among ~160 identical `SQL71502` warnings caused by references to
`sys.*`, `INFORMATION_SCHEMA`, `sp_executesql`, `sp_getapplock` (no `master` reference) and
to `ocms_fn_generatePassword`, an OCMS helper the project never contained;
- a warning does not fail anything.
The same sweep found that `ocms_fn_generatePassword` (and its dependency `ocms_fn_randbetween`)
is missing on the dev database as well, so every id-generating function (`fds__fn_invoice_id`,
`fds__fn_reminder_id`, `fis_fn_id`, …) fails there with Msg 4121.
## Decision
1. `FuchsDatabase.sqlproj` references the `master` system dacpac
(`$(DacPacRootPath)\…\SqlServer\170\SQLSchemas\master.dacpac`), so system objects bind.
2. Everything the SQL references is in the project. The OCMS helpers `ocms_fn_generatePassword`
and `ocms_fn_randbetween` are vendored copies of the OCMSsharp `ocms_mt_database` files.
`Security/Logins.sql` scripts the two application logins with a placeholder,
`MUST_CHANGE` password so the users bind; real passwords stay in Key Vault and SSDT never
compares passwords, so existing logins are untouched on publish.
3. `TreatTSqlWarningsAsErrors` is on. Suppressed: `71501` (dynamic SQL / cross-database
references) and `71558` (identifier differs only by case — harmless under the `CI` model
collation). Every other T-SQL warning, in particular `SQL71502`, fails the build.
4. Building the database project is part of the workflow whenever `Fuchs_Database/**` changes
(`msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug`, from the Visual
Studio MSBuild — `dotnet build` cannot build the legacy SSDT project format). A red database
build is a failure like a red C# build.
5. The C# side of the contract is a test: `Fuchs.Tests/SqlContractTests` scans every
`[dbo].[…]` named in C# strings (`Fuchs`, `Fuchs_DataService`) and checks it against the
SSDT files — the object exists, every `EXECUTE` binds (no unknown names, no surplus
positional arguments, every parameter without a default supplied), every function call
passes the declared argument count, and every `mfr__updt__*` proc declares the
`@tblname, @referencetable, @tgtid` signature `FdsMfrClient` calls dynamically.
## Consequences
- An unqualified column that becomes ambiguous, a typo in a column name, or a call to a
function that is not in the project is a build error before review, not a Msg 209/4121 at
a customer.
- `SELECT *` inside `EXISTS (…)` over joined tables trips `SQL71502` in SSDT even though SQL
Server accepts it; write `SELECT 1` there (done for `fds__fn_IntermediateIsAllocatedToOther`
and `fis_admin_testGroupMembership`).
- New procedures that use `sys.*`/`sp_*` need no suppression any more. A new reference that is
genuinely outside the model (another database, dynamic SQL) is suppressed per statement or
by adding the object to the project — never by widening `SuppressTSqlWarnings` for `71502`.
- A proc signature change that breaks a C# caller fails `dotnet test`, not the first request.
Only literal SQL is covered: dynamically built names (`FuchsVisualization`'s report procs,
resolved from `sys.parameters` at runtime) and result-set column names are not.
- The build proves binding, not behaviour: publishing to the dev database (`SqlPackage
/Action:Publish` with `ExcludeObjectTypes=Logins;Users;Permissions;RoleMembership`, or the
schema compare) and exercising the code path remains the runtime check.
- The dev database needs the two `ocms_*` helpers published before any id-generating path
works there.
## Alternatives considered
- **Run the changed procedures against the dev database before review.** Needs a publish of
every working-tree change first, and does not cover code paths the reviewer does not think
to exercise; the compile-time check is free and total.
- **Suppress `SQL71502` and keep the build warning-free that way.** Removes exactly the signal
that catches this class of bug.
- **A C#-side test that parses the SQL for unqualified columns.** A parser-level heuristic
without a schema cannot decide ambiguity; the SSDT model can.
+48 -8
View File
@@ -97,7 +97,9 @@ payload; see `EVAL_live_invoice_editing.md` for the rationale.
it drives the EN 16931 eRechnung and composes the free-text `SendToAddress` for the it drives the EN 16931 eRechnung and composes the free-text `SendToAddress` for the
PDF (see [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md)). The **service PDF (see [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md)). The **service
date/period** (Leistungsdatum/-zeitraum) is likewise a structured German-date dialog 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. (`$inv.eProvisionPeriod`) — a single date or a from/to range — mapped to BT-72 / BG-14
and printed in the PDF's admin block (`InvoiceService.ApplyProvisionPeriod`; legacy ISO
values are shown as German dates).
- **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns. - **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns.
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice` - **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
(default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational (default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational
@@ -181,6 +183,36 @@ Confirming the preview modal posts to **`req/sconf`**
`[InvoiceId] = fds__newInvoiceId(YEAR())` → format `R<year>-<0000>` `[InvoiceId] = fds__newInvoiceId(YEAR())` → format `R<year>-<0000>`
(the draft only ever had the internal numeric `Id`). `Version` is bumped (the draft only ever had the internal numeric `Id`). `Version` is bumped
and any linked "replaces" bookkeeping (storno/credit chains) is updated. and any linked "replaces" bookkeeping (storno/credit chains) is updated.
- **Invoice date** = `DateFinalized` — printed on the PDF and used as the
e-invoice issue date (`InvoiceDates.IssueDate`, ADR 0032).
- **Backdating (ADR 0032):** users with `fds_inv > 2` see a checkbox in the
preview dialog (`inv/dpreview` returns `backdate`) to date the invoice to
the last working day (Mon–Fri) of the previous month, as long as no
invoice has been finalised after that day. `req/sconf` then passes
`@backdate = 1`; `fds__fn_invoiceBackdate` is re-checked and the proc
throws 60001 (nothing finalised) if it is no longer allowed. Number year and
`DueDate` follow the backdated day. The proc serializes all finalisations
(transaction + `sp_getapplock`) so the check and the numbering cannot
interleave with a concurrent finalisation. The controller maps 60001 to
the "create again without backdating" message; a 0-row result (already
final/sent, e.g. double submit) is reported as "already finalised".
- **Same date everywhere (ADR 0035):** invoice lists, revenue reports,
the DATEV export (period + `Belegdatum`), request details and reminders
all use `[dbo].[fds__fn_invoiceDate]` — `DateFinalized` as German
calendar day, drafts fall back to `DateCreated` — so a backdated invoice
appears in the previous month everywhere, including DATEV. The reminder
PDF's own date follows the same rule (reminder `DateFinalized`).
- **Abschlagsrechnungen (ADR 0034):** `IntInvoiceAllocation` lines carry the
credited intermediate invoice in `reference_InvId` (seeded by
`fds__prepInvoice` and `fds__prepStorno_recreate`, round-tripped by the
editor as item `ref`); finalisation records it in
`fds__invoice_details.AllocatedTo_InvId` so the same Abschlagsrechnung is
not offered again. The claim only counts while the crediting invoice is
alive: once it is cancelled (storno, or replaced via storno + recreate)
`fds__fn_IntermediateIsAllocatedToOther` treats the Abschlagsrechnung as
free again and the next finalisation takes the allocation over. A
Stornorechnung (`'c'`) never claims an allocation, even though a storno
copy mirrors the credit lines.
2. On success (`IsFinal == true` returned), the controller: 2. On success (`IsFinal == true` returned), the controller:
- Reloads the invoice via `LoadInvoiceAsync`. - Reloads the invoice via `LoadInvoiceAsync`.
- Calls `StoreInvoiceDocumentFileAsync` → renders the **final** PDF - Calls `StoreInvoiceDocumentFileAsync` → renders the **final** PDF
@@ -216,7 +248,9 @@ flowchart TD
## 6. Sending the invoice by email ## 6. Sending the invoice by email
Email delivery is handled by `IComService` / `ProcessWebComService`, which Email delivery is handled by `IComService` / `ProcessWebComService`, which
talks to the **ProcessWeb Mailer API** (`push_com`, `comType=email`). talks to the **ProcessWeb Mailer API** (`POST /api/mailer/push_com`,
`comType="e"`; contract: https://processweb.de/api/mailer/fc, client side
described in `Docs/Concepts/processweb-mailer-interface.md`).
- `HandleRequestSconf` builds the HTML body via `BuildInvoiceBody(balance, - `HandleRequestSconf` builds the HTML body via `BuildInvoiceBody(balance,
paymentTerms)` — a fixed German thank-you text, the amount (if non-zero), paymentTerms)` — a fixed German thank-you text, the amount (if non-zero),
@@ -234,8 +268,12 @@ talks to the **ProcessWeb Mailer API** (`push_com`, `comType=email`).
- A signature is appended to the HTML body. - A signature is appended to the HTML body.
- If the mailer is disabled (`_settings.Enabled == false`), sending is a - If the mailer is disabled (`_settings.Enabled == false`), sending is a
documented no-op. documented no-op.
- Attachments are base64-encoded inline (`{ filename, mimeType, - Attachments are pre-validated against the Mailer's limits (size, count,
contentBase64 }`) and POSTed to the Mailer API. no empty file, extension rules) and POSTed as `multipart/form-data` —
envelope JSON in an unnamed field plus one file part per attachment, with
`attachmentCount` in the envelope.
- The Mailer's 200 response must confirm `attachmentsProcessed` equal to
the number sent; otherwise the send is reported as failed (ADR 0028).
- Only on a **successful** send does the controller call - Only on a **successful** send does the controller call
`fds__setInvoiceSent @auto=true`, which sets `IsSent = 1` — so a failed `fds__setInvoiceSent @auto=true`, which sets `IsSent = 1` — so a failed
mailer call leaves the invoice finalised but *not marked sent* (visible via mailer call leaves the invoice finalised but *not marked sent* (visible via
@@ -252,8 +290,10 @@ sequenceDiagram
C->>Com: SendEmailAsync("inv_<Id>", subject, body, email, "", {pdf}) C->>Com: SendEmailAsync("inv_<Id>", subject, body, email, "", {pdf})
Com->>Com: validate email, apply OverrideRecipient (dev), append signature Com->>Com: validate email, apply OverrideRecipient (dev), append signature
alt mailer enabled alt mailer enabled
Com->>API: POST push_com {comType:"email", attachments:[base64 pdf]} Com->>Com: MailerAttachmentPolicy.Validate(pdf)
API-->>Com: success/failure Com->>API: POST /api/mailer/push_com (multipart: envelope{comType:"e", attachmentCount:1} + pdf part)
API-->>Com: 200 {comStackID, attachmentsProcessed} / 4xx/5xx
Com->>Com: attachmentsProcessed == 1 ? success : failure
else mailer disabled else mailer disabled
Com-->>Com: no-op (logged) Com-->>Com: no-op (logged)
end end
@@ -272,10 +312,10 @@ Once an invoice is `IsFinal`, the request/invoice list menus
|---|---|---|---| |---|---|---|---|
| **Resend email** | `$inv.ccInv`/context menu → *(re-)send* → `HandleRequestResend` | `req/resend` | Re-renders the stored/registered PDF bytes (`RenderInvoicePdfBytesAsync`, no re-registration) and re-sends via `IComService`, **without** re-touching `IsSent` | | **Resend email** | `$inv.ccInv`/context menu → *(re-)send* → `HandleRequestResend` | `req/resend` | Re-renders the stored/registered PDF bytes (`RenderInvoicePdfBytesAsync`, no re-registration) and re-sends via `IComService`, **without** re-touching `IsSent` |
| **Mark as sent manually** | `$inv.sis(id)` | `inv/sis` | `fds__setInvoiceSent @auto=false` — for invoices sent outside the system (e.g. printed/posted) | | **Mark as sent manually** | `$inv.sis(id)` | `inv/sis` | `fds__setInvoiceSent @auto=false` — for invoices sent outside the system (e.g. printed/posted) |
| **View / display PDF** | `$inv.disp(id, 'inv')` | `inv/rdoc` | Renders page images for on-screen display only | | **View / display PDF** | `$inv.disp(id, 'inv')` | `inv/rdoc` | Renders page images for on-screen display only. If a finalised, non-external invoice has no stored PDF, it is regenerated and persisted first (ADR 0025) |
| **Download / open PDF** | `req/idoc` or `inv/pget`-derived | `req/idoc` | Serves the stored (or freshly rendered) PDF inline | | **Download / open PDF** | `req/idoc` or `inv/pget`-derived | `req/idoc` | Serves the stored (or freshly rendered) PDF inline |
| **Mark paid / unpaid** | `$inv.setPyd`/`setUpd` | `inv/setpyd` / `inv/setupd` | `fds__setInvoicePayed` / `fds__setInvoiceUNPayed` | | **Mark paid / unpaid** | `$inv.setPyd`/`setUpd` | `inv/setpyd` / `inv/setupd` | `fds__setInvoicePayed` / `fds__setInvoiceUNPayed` |
| **Storno (cancel)** | `$inv.storno` → `$inv.cSt` | `inv/storno` (mode `simple`/`copy`) | `fds__createStorno_simple` / `fds__createStorno_copy`; the replaced invoice is cross-linked (`Replaces_InvId`) and cancelled once the storno itself is finalised | | **Storno (cancel)** | `$inv.storno` → `$inv.cSt` (first `inv/pget`: refreshes mfr entities only for numeric mfr ids, no-op for Intranet ids) | `inv/storno` (mode `simple`/`copy`) | `fds__createStorno_simple` / `fds__createStorno_copy`; the replaced invoice is cross-linked (`Replaces_InvId`) and cancelled once the storno itself is finalised |
| **Credit note** | `$inv.credit` → `$inv.cSt` | `inv/credit` (mode `credit`) | `fds__createCredit_simple` — same preview/finalise/email loop as a normal invoice | | **Credit note** | `$inv.credit` → `$inv.cSt` | `inv/credit` (mode `credit`) | `fds__createCredit_simple` — same preview/finalise/email loop as a normal invoice |
| **Continue editing a draft** | `$inv.cntInv` / `clCntInv` | `inv/get` | Only available while `isFinal == false`; re-opens the same stateless editor | | **Continue editing a draft** | `$inv.cntInv` / `clCntInv` | `inv/get` | Only available while `isFinal == false`; re-opens the same stateless editor |
+5
View File
@@ -49,6 +49,11 @@ Serviceaufträge kommen aus dem MFR-ERP-System und werden regelmäßig automatis
### Erstellen / Bearbeiten ### Erstellen / Bearbeiten
1. Aus einem Serviceauftrag eine Rechnung **vorbereiten** → Vorschau prüfen. 1. Aus einem Serviceauftrag eine Rechnung **vorbereiten** → Vorschau prüfen.
2. **Speichern** (Entwurf) oder weiter **bearbeiten**. Entwürfe tragen ein Wasserzeichen. 2. **Speichern** (Entwurf) oder weiter **bearbeiten**. Entwürfe tragen ein Wasserzeichen.
3. **Rechnung erstellen** in der Vorschau vergibt Rechnungsnummer und Rechnungsdatum (= Tag der Erstellung).
- **Rechnungsdatum Vormonat (nur Admins):** Solange im neuen Monat noch keine Rechnung erstellt wurde,
erscheint in der Vorschau das Häkchen „Rechnungsdatum auf den letzten Werktag des Vormonats setzen
(TT.MM.JJJJ)“. Gesetzt, erhält die Rechnung dieses Datum; Rechnungsnummer und Fälligkeit richten sich
danach. Werktage sind Mo–Fr (Feiertage werden nicht berücksichtigt).
### Sets (Positionen zusammenfassen) ### Sets (Positionen zusammenfassen)
- Ein **Set** aus mfr fasst die darauf folgenden Positionen zusammen; mit **„Auf Setpreis - Ein **Set** aus mfr fasst die darauf folgenden Positionen zusammen; mit **„Auf Setpreis
@@ -1,933 +0,0 @@
# Agent Instructions: Key Vault + DPAPI — Multi-App Architektur (15 Projekte, 1 Server)
## Ausgangslage
- 15 Anwendungen (Web Apps, Console Apps, Windows Services, Worker Services)
- Alle laufen auf demselben Windows-Server
- Ein gemeinsamer Azure Key Vault
- Secrets sind aktuell in `appsettings.json`, `web.config` oder Umgebungsvariablen
- Zwei bestehende Shared Projects: **OCORE** und **OCORE_web**
---
## Aufteilung zwischen OCORE und OCORE_web
```
OCORE OCORE_web
────────────────────────────── ──────────────────────────────
Alles was NICHT Web-spezifisch Nur was WebApplicationBuilder
ist. Funktioniert für: benötigt. Funktioniert für:
- Console Apps - ASP.NET Core Web Apps
- Windows Services - ASP.NET Core Web APIs
- Worker Services - Razor Pages / MVC
- WinForms / WPF
- Web Apps (via OCORE_web)
Enthält: Enthält:
AppSecretsOptions SecretManagementWebExtensions
DpapiSecretsCache (eine einzige Datei)
DpapiCacheConfigurationSource
KeyVaultSyncService
PrefixKeyVaultSecretManager
SecretManagementExtensions
(IHostBuilder)
```
> OCORE_web referenziert OCORE — d.h. Web Apps brauchen nur OCORE_web
> zu referenzieren, OCORE kommt transitiv mit.
---
## Architektur-Übersicht
```
┌─────────────────────────────────────────────────────────────────┐
│ Azure Key Vault │
│ │
│ crm--ConnectionStrings--Database → CRM Web App │
│ crm--ExternalApi--ApiKey → CRM Web App │
│ erp--ConnectionStrings--Database → ERP Web App │
│ erp--Smtp--Password → ERP Web App │
│ importer--ConnectionStrings--Db → Importer Console App │
│ scheduler--ConnectionStrings--Db → Scheduler Windows Svc │
│ ... │
└──────────────────────────┬──────────────────────────────────────┘
│ Managed Identity (Server-Level)
┌──────────────────────────▼──────────────────────────────────────┐
│ Windows Server │
│ │
│ C:\inetpub\crm\ secrets.cache (DPAPI, crm-Entropy) │
│ C:\inetpub\erp\ secrets.cache (DPAPI, erp-Entropy) │
│ C:\services\importer\ secrets.cache (DPAPI, importer-Entropy) │
│ C:\services\scheduler\secrets.cache (DPAPI, scheduler-Entropy)│
│ ... │
│ │
│ OCORE.dll ← alle 15 Apps referenzieren dies │
│ OCORE_web.dll ← nur Web Apps referenzieren dies zusätzlich │
└─────────────────────────────────────────────────────────────────┘
```
### Wichtige Designentscheidungen
| Thema | Entscheidung | Begründung |
|---|---|---|
| Key Vault | Ein gemeinsamer | Einfacher zu verwalten, RBAC auf Secret-Ebene möglich |
| Namespacing | `{appname}--{key}` Präfix | Verhindert Kollisionen zwischen Apps |
| DPAPI Scope | `LocalMachine` + App-spezifische Entropy | Isolation: App A kann Cache von App B nicht lesen |
| Basiscode | OCORE | Kein Web-Framework nötig, funktioniert überall |
| Web-Extension | OCORE_web | Nur eine Datei, dünne Schicht über OCORE |
| App-Identity | Eine Server-Managed-Identity | Alle Apps teilen dieselbe Identity, RBAC per Secret |
---
## Teil 1: OCORE — NuGet-Pakete hinzufügen
```bash
cd OCORE
dotnet add package Azure.Extensions.AspNetCore.Configuration.Secrets
dotnet add package Azure.Identity
dotnet add package Azure.Security.KeyVault.Secrets
dotnet add package Microsoft.Extensions.Hosting.Abstractions
dotnet add package Microsoft.Extensions.Configuration.Abstractions
dotnet add package Microsoft.Extensions.Logging.Abstractions
```
---
## Teil 2: OCORE — Dateien anlegen
Neue Ordnerstruktur **innerhalb von OCORE** (bestehende Struktur bleibt unverändert):
```
OCORE/
... (bestehende Dateien)
Secrets/
Models/
AppSecretsOptions.cs
Cache/
DpapiSecretsCache.cs
DpapiCacheConfigurationSource.cs
Sync/
KeyVaultSyncService.cs
Extensions/
SecretManagementExtensions.cs ← IHostBuilder Extension + gemeinsame Logik
PrefixKeyVaultSecretManager.cs
```
---
### 2.1 OCORE/Secrets/Models/AppSecretsOptions.cs
```csharp
namespace OCORE.Secrets.Models;
public class AppSecretsOptions
{
public const string SectionName = "SecretManagement";
/// <summary>
/// Vollständige URI des Azure Key Vault.
/// Beispiel: https://company-vault.vault.azure.net/
/// </summary>
public string VaultUri { get; set; } = string.Empty;
/// <summary>
/// App-Name als Präfix für alle Secrets in Key Vault.
/// Beispiel: "crm" → Secret "crm--ConnectionStrings--Database"
/// Nur Kleinbuchstaben und Bindestriche (Key Vault Konvention).
/// </summary>
public string AppName { get; set; } = string.Empty;
/// <summary>
/// Pfad zur DPAPI-Cache-Datei (relativ zum App-Verzeichnis).
/// </summary>
public string CacheFilePath { get; set; } = "secrets.cache";
/// <summary>
/// Sync-Intervall in Stunden.
/// </summary>
public int SyncIntervalHours { get; set; } = 6;
/// <summary>
/// Secret-Namen ohne App-Präfix.
/// Beispiel: ["ConnectionStrings--Database", "ExternalApi--ApiKey"]
/// </summary>
public List<string> ManagedSecretKeys { get; set; } = [];
}
```
---
### 2.2 OCORE/Secrets/Cache/DpapiSecretsCache.cs
```csharp
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace OCORE.Secrets.Cache;
public interface IDpapiSecretsCache
{
Task WriteAsync(Dictionary<string, string> secrets);
Task<Dictionary<string, string>> ReadAsync();
bool Exists();
}
public class DpapiSecretsCache : IDpapiSecretsCache
{
private readonly string _filePath;
private readonly byte[] _entropy;
private readonly ILogger<DpapiSecretsCache> _logger;
/// <summary>
/// Entropy wird aus AppName abgeleitet — verhindert dass eine andere App
/// auf demselben Server den Cache dieser App lesen kann.
/// </summary>
public DpapiSecretsCache(string filePath, string appName, ILogger<DpapiSecretsCache> logger)
{
_filePath = filePath;
_entropy = Encoding.UTF8.GetBytes($"dpapi-entropy-{appName}");
_logger = logger;
}
public async Task WriteAsync(Dictionary<string, string> secrets)
{
try
{
var json = JsonSerializer.SerializeToUtf8Bytes(secrets);
var encrypted = ProtectedData.Protect(json, _entropy, DataProtectionScope.LocalMachine);
await File.WriteAllBytesAsync(_filePath, encrypted);
_logger.LogInformation("[OCORE.Secrets] DPAPI Cache geschrieben: {Count} Secrets → {Path}",
secrets.Count, _filePath);
}
catch (Exception ex)
{
_logger.LogError(ex, "[OCORE.Secrets] DPAPI Cache konnte nicht geschrieben werden: {Path}", _filePath);
}
}
public async Task<Dictionary<string, string>> ReadAsync()
{
if (!Exists()) return [];
try
{
var encrypted = await File.ReadAllBytesAsync(_filePath);
var decrypted = ProtectedData.Unprotect(encrypted, _entropy, DataProtectionScope.LocalMachine);
return JsonSerializer.Deserialize<Dictionary<string, string>>(decrypted) ?? [];
}
catch (CryptographicException ex)
{
_logger.LogError(ex,
"[OCORE.Secrets] DPAPI Entschlüsselung fehlgeschlagen — AppName korrekt? {Path}", _filePath);
return [];
}
catch (Exception ex)
{
_logger.LogError(ex, "[OCORE.Secrets] DPAPI Cache Lesefehler: {Path}", _filePath);
return [];
}
}
public bool Exists() => File.Exists(_filePath);
}
```
---
### 2.3 OCORE/Secrets/Cache/DpapiCacheConfigurationSource.cs
```csharp
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace OCORE.Secrets.Cache;
public class DpapiCacheConfigurationSource : IConfigurationSource
{
private readonly string _filePath;
private readonly string _appName;
public DpapiCacheConfigurationSource(string filePath, string appName)
{
_filePath = filePath;
_appName = appName;
}
public IConfigurationProvider Build(IConfigurationBuilder builder)
=> new DpapiCacheConfigurationProvider(_filePath, _appName);
}
public class DpapiCacheConfigurationProvider : ConfigurationProvider
{
private readonly string _filePath;
private readonly string _appName;
public DpapiCacheConfigurationProvider(string filePath, string appName)
{
_filePath = filePath;
_appName = appName;
}
public override void Load()
{
if (!File.Exists(_filePath)) return;
try
{
using var loggerFactory = LoggerFactory.Create(b => b.AddConsole());
var logger = loggerFactory.CreateLogger<DpapiSecretsCache>();
var cache = new DpapiSecretsCache(_filePath, _appName, logger);
var secrets = cache.ReadAsync().GetAwaiter().GetResult();
// "crm--ConnectionStrings--Database" → "ConnectionStrings:Database"
var prefix = $"{_appName}--";
Data = secrets
.Where(kvp => kvp.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
.ToDictionary(
kvp => kvp.Key[prefix.Length..].Replace("--", ":"),
kvp => (string?)kvp.Value);
}
catch (Exception ex)
{
Console.WriteLine($"[OCORE.Secrets] DPAPI Cache Ladefehler: {ex.Message}");
Data = [];
}
}
}
```
---
### 2.4 OCORE/Secrets/Sync/KeyVaultSyncService.cs
```csharp
using Azure.Security.KeyVault.Secrets;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OCORE.Secrets.Cache;
using OCORE.Secrets.Models;
namespace OCORE.Secrets.Sync;
/// <summary>
/// Läuft als BackgroundService in jedem Host-Typ:
/// Web Apps, Console Apps, Windows Services, Worker Services.
/// Synct Secrets aus Key Vault → DPAPI-Cache, periodisch.
/// </summary>
public class KeyVaultSyncService : BackgroundService
{
private readonly SecretClient _secretClient;
private readonly IDpapiSecretsCache _cache;
private readonly IConfigurationRoot _configRoot;
private readonly AppSecretsOptions _options;
private readonly ILogger<KeyVaultSyncService> _logger;
public KeyVaultSyncService(
SecretClient secretClient,
IDpapiSecretsCache cache,
IConfiguration configuration,
IOptions<AppSecretsOptions> options,
ILogger<KeyVaultSyncService> logger)
{
_secretClient = secretClient;
_cache = cache;
_configRoot = (IConfigurationRoot)configuration;
_options = options.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await SyncAsync(stoppingToken);
using var timer = new PeriodicTimer(TimeSpan.FromHours(_options.SyncIntervalHours));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await SyncAsync(stoppingToken);
}
}
private async Task SyncAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("[OCORE.Secrets:{App}] Key Vault Sync gestartet", _options.AppName);
var synced = new Dictionary<string, string>();
foreach (var key in _options.ManagedSecretKeys)
{
var fullName = $"{_options.AppName}--{key}";
try
{
var secret = await _secretClient.GetSecretAsync(fullName, version: null, cancellationToken);
synced[fullName] = secret.Value.Value;
}
catch (Exception ex)
{
_logger.LogWarning("[OCORE.Secrets:{App}] Secret '{Name}' nicht geladen: {Error}",
_options.AppName, fullName, ex.Message);
}
}
if (synced.Count > 0)
{
await _cache.WriteAsync(synced);
_configRoot.Reload();
_logger.LogInformation("[OCORE.Secrets:{App}] Sync OK: {Count}/{Total}",
_options.AppName, synced.Count, _options.ManagedSecretKeys.Count);
}
else
{
_logger.LogWarning("[OCORE.Secrets:{App}] Sync: Keine Secrets geladen — Cache unverändert",
_options.AppName);
}
}
}
```
---
### 2.5 OCORE/Secrets/Extensions/PrefixKeyVaultSecretManager.cs
```csharp
using Azure.Extensions.AspNetCore.Configuration.Secrets;
using Azure.Security.KeyVault.Secrets;
namespace OCORE.Secrets.Extensions;
/// <summary>
/// Filtert beim Key Vault Laden nur Secrets mit dem App-Präfix
/// und konvertiert den Namen in einen IConfiguration-Key.
/// "crm--ConnectionStrings--Database" → "ConnectionStrings:Database"
/// </summary>
public class PrefixKeyVaultSecretManager : KeyVaultSecretManager
{
private readonly string _prefix;
public PrefixKeyVaultSecretManager(string appName)
=> _prefix = $"{appName}--";
public override bool Load(SecretProperties secret)
=> secret.Name.StartsWith(_prefix, StringComparison.OrdinalIgnoreCase);
public override string GetKey(KeyVaultSecret secret)
=> secret.Name[_prefix.Length..].Replace("--", ":");
}
```
---
### 2.6 OCORE/Secrets/Extensions/SecretManagementExtensions.cs
```csharp
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using OCORE.Secrets.Cache;
using OCORE.Secrets.Models;
using OCORE.Secrets.Sync;
namespace OCORE.Secrets.Extensions;
public static class SecretManagementExtensions
{
/// <summary>
/// Für Console Apps, Worker Services, Windows Services, WinForms/WPF.
/// Verwendung: Host.CreateDefaultBuilder(args).AddSecretManagement()
/// </summary>
public static IHostBuilder AddSecretManagement(this IHostBuilder hostBuilder)
{
hostBuilder.ConfigureAppConfiguration((context, config) =>
{
var built = config.Build();
var options = built.GetSection(AppSecretsOptions.SectionName).Get<AppSecretsOptions>()
?? new AppSecretsOptions();
if (!IsConfigured(options)) return;
var cacheFilePath = ResolveCachePath(
context.HostingEnvironment.ContentRootPath, options.CacheFilePath);
// 1. DPAPI-Cache (Fallback)
config.Add(new DpapiCacheConfigurationSource(cacheFilePath, options.AppName));
// 2. Key Vault (primär — überschreibt Cache wenn erreichbar)
TryAddKeyVault(config, options);
});
hostBuilder.ConfigureServices((context, services) =>
{
RegisterCoreServices(services, context.Configuration);
});
return hostBuilder;
}
/// <summary>
/// Gemeinsame Service-Registrierung — wird von OCORE und OCORE_web verwendet.
/// </summary>
public static void RegisterCoreServices(IServiceCollection services, IConfiguration configuration)
{
var options = configuration
.GetSection(AppSecretsOptions.SectionName)
.Get<AppSecretsOptions>() ?? new AppSecretsOptions();
if (!IsConfigured(options)) return;
services.Configure<AppSecretsOptions>(
configuration.GetSection(AppSecretsOptions.SectionName));
services.AddSingleton(new SecretClient(
new Uri(options.VaultUri),
new DefaultAzureCredential()));
services.AddSingleton<IDpapiSecretsCache>(sp =>
new DpapiSecretsCache(
ResolveCachePath(AppContext.BaseDirectory, options.CacheFilePath),
options.AppName,
sp.GetRequiredService<ILogger<DpapiSecretsCache>>()));
services.AddHostedService<KeyVaultSyncService>();
}
public static void TryAddKeyVault(IConfigurationBuilder config, AppSecretsOptions options)
{
try
{
config.AddAzureKeyVault(
new Uri(options.VaultUri),
new DefaultAzureCredential(),
new PrefixKeyVaultSecretManager(options.AppName));
}
catch (Exception ex)
{
Console.WriteLine($"[OCORE.Secrets] Key Vault nicht erreichbar: {ex.Message}");
Console.WriteLine("[OCORE.Secrets] Starte mit DPAPI-Cache");
}
}
public static bool IsConfigured(AppSecretsOptions options)
=> !string.IsNullOrEmpty(options.VaultUri) && !string.IsNullOrEmpty(options.AppName);
public static string ResolveCachePath(string basePath, string cacheFilePath)
=> Path.IsPathRooted(cacheFilePath)
? cacheFilePath
: Path.Combine(basePath, cacheFilePath);
}
```
---
## Teil 3: OCORE_web — Einzige neue Datei
OCORE_web referenziert OCORE bereits. Nur eine neue Datei hinzufügen:
```
OCORE_web/
... (bestehende Dateien)
Secrets/
SecretManagementWebExtensions.cs ← einzige neue Datei in OCORE_web
```
### 3.1 OCORE_web/Secrets/SecretManagementWebExtensions.cs
```csharp
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using OCORE.Secrets.Extensions;
using OCORE.Secrets.Models;
namespace OCORE_web.Secrets;
public static class SecretManagementWebExtensions
{
/// <summary>
/// Für ASP.NET Core Web Apps und Web APIs.
/// Verwendung: builder.AddSecretManagement()
/// </summary>
public static WebApplicationBuilder AddSecretManagement(
this WebApplicationBuilder builder)
{
var options = builder.Configuration
.GetSection(AppSecretsOptions.SectionName)
.Get<AppSecretsOptions>() ?? new AppSecretsOptions();
if (!SecretManagementExtensions.IsConfigured(options)) return builder;
var cacheFilePath = SecretManagementExtensions.ResolveCachePath(
builder.Environment.ContentRootPath, options.CacheFilePath);
// 1. DPAPI-Cache (Fallback)
builder.Configuration.Add(
new OCORE.Secrets.Cache.DpapiCacheConfigurationSource(cacheFilePath, options.AppName));
// 2. Key Vault (primär)
SecretManagementExtensions.TryAddKeyVault(builder.Configuration, options);
// 3. Services — aus OCORE (kein duplizierter Code)
SecretManagementExtensions.RegisterCoreServices(builder.Services, builder.Configuration);
return builder;
}
}
```
---
## Teil 4: Verwendung je App-Typ
### Web App (referenziert OCORE_web)
```csharp
// Program.cs
using OCORE_web.Secrets;
var builder = WebApplication.CreateBuilder(args);
builder.AddSecretManagement(); // ← aus OCORE_web
var app = builder.Build();
app.Run();
```
### Console App / Worker Service / Windows Service (referenziert OCORE)
```csharp
// Program.cs
using OCORE.Secrets.Extensions;
// Console / Worker
var host = Host.CreateDefaultBuilder(args)
.AddSecretManagement() // ← aus OCORE
.ConfigureServices(services =>
{
services.AddHostedService<MyWorker>();
})
.Build();
await host.RunAsync();
```
```csharp
// Windows Service — identisch, nur UseWindowsService() zusätzlich
var host = Host.CreateDefaultBuilder(args)
.UseWindowsService()
.AddSecretManagement() // ← aus OCORE
.ConfigureServices(services =>
{
services.AddHostedService<MyWindowsService>();
})
.Build();
await host.RunAsync();
```
### WinForms / WPF (referenziert OCORE)
```csharp
// Program.cs — Generic Host nachrüsten
using OCORE.Secrets.Extensions;
var host = Host.CreateDefaultBuilder()
.AddSecretManagement() // ← aus OCORE
.ConfigureServices(services =>
{
services.AddSingleton<MainForm>();
})
.Build();
Application.Run(host.Services.GetRequiredService<MainForm>());
```
---
## Teil 5: appsettings.json (identisch für alle App-Typen)
```json
{
"SecretManagement": {
"VaultUri": "https://company-vault.vault.azure.net/",
"AppName": "crm",
"CacheFilePath": "secrets.cache",
"SyncIntervalHours": 6,
"ManagedSecretKeys": [
"ConnectionStrings--Database",
"ExternalApi--ApiKey"
]
},
"ConnectionStrings": {
"Database": "MANAGED_BY_KEYVAULT"
}
}
```
> `AppName` ist der einzige Wert der sich zwischen den 15 Projekten unterscheidet.
---
## Teil 6: Key Vault Naming-Konvention
```
Format: {appname}--{ConfigSection}--{ConfigKey}
Web Apps:
crm--ConnectionStrings--Database
crm--ExternalApi--ApiKey
erp--ConnectionStrings--Database
erp--Smtp--Password
portal--Auth--JwtSecret
Non-Web Apps:
importer--ConnectionStrings--Database
scheduler--ConnectionStrings--Database
scheduler--Smtp--Password
```
### Naming-Regeln
- Nur alphanumerische Zeichen und einfache Bindestriche `-`
- Hierarchie-Trenner: `--` (doppelter Bindestrich)
- Kein Unterstrich, kein Punkt, kein Leerzeichen
- Maximal 127 Zeichen
---
## Teil 7: Einmalige Migration — Alle Apps
### 7.1 Inventur-Script
```powershell
# inventarisierung.ps1
param([string]$SolutionRoot = "C:\Projects\MySolution")
$patterns = @(
'password\s*=\s*["\'][^"\']+["\']',
'connectionstring\s*=\s*["\'][^"\']+["\']',
'apikey\s*=\s*["\'][^"\']+["\']',
'secret\s*=\s*["\'][^"\']+["\']',
'pwd\s*=\s*[^;]+'
)
Get-ChildItem -Path $SolutionRoot -Recurse -Include "*.json","*.config","*.cs" |
Where-Object { $_.FullName -notmatch '\\(bin|obj|\.git)\\' } |
ForEach-Object {
$file = $_
$content = Get-Content $file.FullName -Raw -ErrorAction SilentlyContinue
foreach ($pattern in $patterns) {
if ($content -match $pattern) {
Write-Host "⚠️ $($file.FullName)" -ForegroundColor Yellow
break
}
}
}
```
### 7.2 Migration Script
```powershell
# migrate-all-to-keyvault.ps1
param([Parameter(Mandatory)][string]$VaultName)
Connect-AzAccount
$allSecrets = @{
# Web Apps
"crm--ConnectionStrings--Database" = "Server=PROD-SQL;Database=CrmDb;..."
"crm--ExternalApi--ApiKey" = "crm-api-key"
"erp--ConnectionStrings--Database" = "Server=PROD-SQL;Database=ErpDb;..."
"erp--Smtp--Password" = "smtp-passwort"
"portal--ConnectionStrings--Database" = "Server=PROD-SQL;Database=PortalDb;..."
"portal--Auth--JwtSecret" = "jwt-secret"
# Non-Web Apps (OCORE)
"importer--ConnectionStrings--Database" = "Server=PROD-SQL;Database=ImportDb;..."
"scheduler--ConnectionStrings--Database" = "Server=PROD-SQL;Database=SchedDb;..."
"scheduler--Smtp--Password" = "smtp-passwort"
# Weitere Apps ergänzen...
}
$total = $allSecrets.Count
$done = 0
foreach ($name in $allSecrets.Keys) {
$value = ConvertTo-SecureString $allSecrets[$name] -AsPlainText -Force
Set-AzKeyVaultSecret -VaultName $VaultName -Name $name -SecretValue $value
$done++
Write-Progress -Activity "Migriere Secrets" -Status "$name" -PercentComplete (($done/$total)*100)
Write-Host "✅ [$done/$total] $name"
}
Write-Host "`nMigration abgeschlossen." -ForegroundColor Green
```
### 7.3 Bereinigung
```powershell
# bereinigung.ps1
$projektOrdner = @(
"C:\Projects\CRM",
"C:\Projects\ERP",
"C:\Projects\Portal",
"C:\Projects\Importer",
"C:\Projects\Scheduler"
# Alle Pfade eintragen
)
foreach ($ordner in $projektOrdner) {
$file = Join-Path $ordner "appsettings.json"
if (Test-Path $file) {
$json = Get-Content $file -Raw | ConvertFrom-Json
if ($json.ConnectionStrings) {
$json.ConnectionStrings.PSObject.Properties |
ForEach-Object { $_.Value = "MANAGED_BY_KEYVAULT" }
}
$json | ConvertTo-Json -Depth 10 | Set-Content $file
Write-Host "✅ Bereinigt: $file"
}
}
```
---
## Teil 8: Server-Setup (einmalig)
### 8.1 Managed Identity + Key Vault Zugriff
```powershell
# server-setup.ps1 — einmalig als Admin
# Managed Identity der VM abrufen
$identity = Get-AzVM -ResourceGroupName "MyRG" -Name "PROD-SERVER-01"
$objectId = $identity.Identity.PrincipalId
# Key Vault: Server darf lesen, nicht schreiben
Set-AzKeyVaultAccessPolicy `
-VaultName "company-vault" `
-ObjectId $objectId `
-PermissionsToSecrets Get, List
Write-Host "✅ Key Vault Zugriff konfiguriert für Object ID: $objectId"
```
### 8.2 Schreibrechte für Cache-Dateien
```powershell
# Für IIS Web Apps (App Pool User)
$webApps = @("CRM", "ERP", "Portal")
foreach ($pool in $webApps) {
$appPath = "C:\inetpub\$pool"
$user = "IIS AppPool\$pool"
$acl = Get-Acl $appPath
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
$user, "Modify", "ContainerInherit,ObjectInherit", "None", "Allow")
$acl.SetAccessRule($rule)
Set-Acl -Path $appPath -AclObject $acl
Write-Host "✅ Web App Schreibrecht: $user → $appPath"
}
# Für Windows Services / Console Apps (LocalSystem oder dedizierter Service-User)
$serviceApps = @("Importer", "Scheduler")
foreach ($svc in $serviceApps) {
$appPath = "C:\services\$svc"
# LocalSystem hat standardmäßig Schreibrecht — nur prüfen ob Ordner existiert
if (-not (Test-Path $appPath)) {
New-Item -ItemType Directory -Path $appPath
Write-Host "✅ Service-Ordner erstellt: $appPath"
}
}
```
---
## Teil 9: .gitignore (Solution-Level)
```gitignore
# Secret Management
secrets.cache
**/secrets.cache
# Konfigurationsdateien mit echten Werten
appsettings.Production.json
appsettings.Staging.json
# Zertifikate
*.pfx
*.p12
*.key
```
---
## Teil 10: Pro-Projekt Checkliste
### Web Apps (OCORE_web)
```
[ ] 1. AppName festlegen (kurz, eindeutig, Kleinbuchstaben)
[ ] 2. OCORE_web bereits referenziert (transitiv: OCORE kommt mit)
[ ] 3. Program.cs:
using OCORE_web.Secrets;
builder.AddSecretManagement();
[ ] 4. appsettings.json: SecretManagement-Block mit korrektem AppName
[ ] 5. ManagedSecretKeys vollständig auflisten
[ ] 6. Secrets in Key Vault mit {appname}-- Präfix vorhanden
[ ] 7. Klartext-Secrets aus appsettings.json / web.config entfernt
[ ] 8. secrets.cache in .gitignore
[ ] 9. Logs nach Deploy prüfen:
"[OCORE.Secrets:crm] Sync OK" → ✅
"[OCORE.Secrets:crm] Key Vault nicht erreichbar" → ❌
```
### Non-Web Apps (OCORE)
```
[ ] 1. AppName festlegen (kurz, eindeutig, Kleinbuchstaben)
[ ] 2. OCORE bereits referenziert (vorhanden)
[ ] 3. Program.cs:
using OCORE.Secrets.Extensions;
Host.CreateDefaultBuilder(args).AddSecretManagement()
(Windows Service: zusätzlich .UseWindowsService())
[ ] 4. appsettings.json: SecretManagement-Block mit korrektem AppName
[ ] 5. ManagedSecretKeys vollständig auflisten
[ ] 6. Secrets in Key Vault mit {appname}-- Präfix vorhanden
[ ] 7. Klartext-Secrets aus Konfigurationsdateien entfernt
[ ] 8. secrets.cache in .gitignore
[ ] 9. Logs prüfen nach erstem Start
```
---
## Zusammenfassung der Artefakte
```
OCORE/Secrets/ ← Einmalig anlegen
Models/AppSecretsOptions.cs
Cache/DpapiSecretsCache.cs
Cache/DpapiCacheConfigurationSource.cs
Sync/KeyVaultSyncService.cs
Extensions/PrefixKeyVaultSecretManager.cs
Extensions/SecretManagementExtensions.cs ← IHostBuilder + shared helpers
OCORE_web/Secrets/ ← Einmalig anlegen (1 Datei)
SecretManagementWebExtensions.cs ← WebApplicationBuilder Extension
Pro Web-Projekt (15x oder subset):
Program.cs → +2 Zeilen (using + builder.AddSecretManagement())
appsettings.json → SecretManagement-Block, keine Secrets mehr
Pro Non-Web-Projekt:
Program.cs → +2 Zeilen (using + .AddSecretManagement())
appsettings.json → SecretManagement-Block, keine Secrets mehr
Scripts/
inventarisierung.ps1 ← Wo liegen aktuell Secrets?
migrate-all-to-keyvault.ps1 ← Einmalige Migration aller Apps
bereinigung.ps1 ← Platzhalter setzen nach Migration
server-setup.ps1 ← Managed Identity + ACLs (einmalig)
```
+11 -11
View File
@@ -32,14 +32,14 @@
<!-- Compatible packages (kept) --> <!-- Compatible packages (kept) -->
<PackageReference Include="BouncyCastle" Version="1.8.9" /> <PackageReference Include="BouncyCastle" Version="1.8.9" />
<PackageReference Include="HtmlAgilityPack" Version="1.13.0" /> <PackageReference Include="HtmlAgilityPack" Version="1.13.0" />
<PackageReference Include="MailKit" Version="4.18.0" /> <PackageReference Include="MailKit" Version="4.18.1" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" /> <PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.18.0" /> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.19.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.18.0" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.19.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.18.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.19.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.18.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.19.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.18.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.19.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.18.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.19.0" />
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" /> <PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
<PackageReference Include="QRCoder" Version="1.8.0" /> <PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="PDFsharp" Version="6.2.4" /> <PackageReference Include="PDFsharp" Version="6.2.4" />
@@ -47,14 +47,14 @@
<PackageReference Include="Spire.PDF" Version="[8.10.5,8.10.5]" allowedVersions="[8.10.5,8.10.5]" /> <PackageReference Include="Spire.PDF" Version="[8.10.5,8.10.5]" allowedVersions="[8.10.5,8.10.5]" />
<!-- Updated packages --> <!-- Updated packages -->
<PackageReference Include="BouncyCastle.Cryptography" Version="2.7.0" /> <PackageReference Include="BouncyCastle.Cryptography" Version="2.7.0" />
<PackageReference Include="MimeKit" Version="4.18.0" /> <PackageReference Include="MimeKit" Version="4.18.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<!-- New packages (needed for .NET 10) --> <!-- New packages (needed for .NET 10) -->
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.3" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="7.1.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="4.1.2" /> <PackageReference Include="SixLabors.ImageSharp" Version="4.1.3" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.12" /> <PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.12" />
<PackageReference Include="System.Drawing.Common" Version="10.0.12" /> <PackageReference Include="System.Drawing.Common" Version="10.0.12" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.29.2" /> <PackageReference Include="Azure.Storage.Blobs" Version="12.30.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="App_Data\cache\" /> <Folder Include="App_Data\cache\" />
@@ -0,0 +1,41 @@
using Fuchs.intranet;
namespace Fuchs.Logging;
/// <summary>
/// Pushes a per-request logging scope (request id, path, and — once cookie auth has populated
/// <see cref="HttpContext.User"/> — the authenticated user id) so every log line written while
/// handling this request, including ones written deep inside OCORE (SQL errors, PDF render
/// failures, ...), carries that attribution automatically. See ADR 0024: the scope flows into
/// every sink's serialized context (<c>LogEntry.dataSerialized</c>, via
/// <see cref="OCORE.MultiLogger.MultiLogger_base"/>'s context merging) and — for "authuser"
/// specifically — into the dedicated <see cref="OCORE.MultiLogger.LogEntry.AuthUser"/> column the
/// SQL sink writes to <c>fds__debuglog.AuthUser</c>.
/// Registered after <c>UseAuthentication()</c> so the user claim is already populated.
/// </summary>
public sealed class RequestLogScopeMiddleware
{
private readonly RequestDelegate _next;
public RequestLogScopeMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context, ILogger<RequestLogScopeMiddleware> logger)
{
using (logger.BeginScope(BuildScope(context)))
await _next(context);
}
/// <summary>Pure so the scope shape is unit-testable without a live request pipeline.</summary>
internal static Dictionary<string, object> BuildScope(HttpContext context)
{
var scope = new Dictionary<string, object>
{
["requestId"] = context.TraceIdentifier,
["path"] = context.Request.Path.Value ?? "",
};
string? authUser = context.User?.FindFirst(FuchsUserIdentity.ClaimUserAccountId)?.Value;
if (!string.IsNullOrEmpty(authUser))
scope["authuser"] = authUser;
return scope;
}
}
+15
View File
@@ -51,12 +51,27 @@ public static class FuchsTelemetry
Meter.CreateCounter<long>("fuchs.blobstorage.uploads", "{upload}", "Number of documents successfully archived to Azure Blob Storage."); Meter.CreateCounter<long>("fuchs.blobstorage.uploads", "{upload}", "Number of documents successfully archived to Azure Blob Storage.");
public static readonly Counter<long> BlobUploadsFailed = public static readonly Counter<long> BlobUploadsFailed =
Meter.CreateCounter<long>("fuchs.blobstorage.uploads.failed", "{upload}", "Number of documents that failed to archive to Azure Blob Storage."); Meter.CreateCounter<long>("fuchs.blobstorage.uploads.failed", "{upload}", "Number of documents that failed to archive to Azure Blob Storage.");
public static readonly Counter<long> BlobBackupCopiesSucceeded =
Meter.CreateCounter<long>("fuchs.blobstorage.backupcopies", "{copy}",
"Number of archived blobs successfully copied to a backup name (admin invoice-file backup/clear).");
public static readonly Counter<long> BlobBackupCopiesFailed =
Meter.CreateCounter<long>("fuchs.blobstorage.backupcopies.failed", "{copy}",
"Number of blob backup-copy attempts that failed.");
public static readonly Counter<long> InvoicesBackdated =
Meter.CreateCounter<long>("fuchs.invoices.backdated", "{invoice}",
"Invoice finalisations with the admin backdate option (ADR 0032), tagged by outcome.");
public static readonly Counter<long> InvoiceFileBackups =
Meter.CreateCounter<long>("fuchs.invoice.filebackups", "{backup}",
"Admin-triggered invoice file backup+clear actions (Do_Process_Admin backupclearinvoicefile), tagged by outcome.");
public static readonly Counter<long> SystemProbes = public static readonly Counter<long> SystemProbes =
Meter.CreateCounter<long>("fuchs.systemstatus.probes", "{probe}", Meter.CreateCounter<long>("fuchs.systemstatus.probes", "{probe}",
"Admin system-status connectivity probes executed, tagged by component and outcome."); "Admin system-status connectivity probes executed, tagged by component and outcome.");
public static readonly Counter<long> InvoiceInlineImageFetches = public static readonly Counter<long> InvoiceInlineImageFetches =
Meter.CreateCounter<long>("fuchs.invoice.inlineimages.fetches", "{fetch}", Meter.CreateCounter<long>("fuchs.invoice.inlineimages.fetches", "{fetch}",
"Invoice-text image fetches for inline PDF rendering (ADR 0018), tagged by outcome."); "Invoice-text image fetches for inline PDF rendering (ADR 0018), tagged by outcome.");
public static readonly Counter<long> PdfPreviews =
Meter.CreateCounter<long>("fuchs.pdf.previews", "{preview}",
"PDF preview requests (draft/invoice/reminder/report image previews), tagged by endpoint and outcome.");
// ── Performance histograms (durations in milliseconds) ─────────────────── // ── Performance histograms (durations in milliseconds) ───────────────────
public static readonly Histogram<double> PdfRenderDuration = public static readonly Histogram<double> PdfRenderDuration =
+74 -2
View File
@@ -8,8 +8,11 @@ using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.StaticFiles; using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OCORE.MultiLogger;
using OCORE.security; using OCORE.security;
using OCORE.web; using OCORE.web;
using OCORE.web.pwmailer;
using OpenTelemetry.Metrics; using OpenTelemetry.Metrics;
using OpenTelemetry.Resources; using OpenTelemetry.Resources;
using OpenTelemetry.Trace; using OpenTelemetry.Trace;
@@ -53,6 +56,26 @@ public class Program
// Initialize the Fuchs intranet singleton with configuration // Initialize the Fuchs intranet singleton with configuration
FuchsOcmsIntranet.Initialize(builder.Configuration); FuchsOcmsIntranet.Initialize(builder.Configuration);
// SQL log sink (Warning+, ADR 0024): every _logger.LogWarning/-Error/-Critical call — not
// just the explicit ones that used to call Fuchs_intranet.debug_log — additionally lands in
// [dbo].[fds__debuglog] via [dbo].[fds__admin_logdebug_ext], with the failing SQL/context
// (redacted), the ILogger category, and — via RequestLogScopeMiddleware's scope — the
// request id and authenticated user, all in dedicated/serialized columns. The file sink
// above (AddFuchsLogging) stays the always-on baseline; this is the durable, queryable copy
// support actually reads from. Config switch (default on) allows disabling per environment.
if (builder.Configuration.GetValue("Fuchs:Logging:SqlSink:Enabled", true))
{
builder.Logging.AddSqlLogger();
builder.Services.Configure<MultiLoggerOptions>(o =>
{
o.SQL.ConnectionString = FuchsOcmsIntranet.Instance.Intranet__SQLConnectionString;
o.SQL.StoredProcedure = "dbo.fds__admin_logdebug_ext";
o.SQL.LogLevel = LogLevel.Warning;
o.SQL.TruncateTextAt = 4000;
o.SQL.IncludeCategory = true; // fds__admin_logdebug_ext declares @category
});
}
// Initialize FdsConfig so FdsMfr / FdsMfrClient can resolve connection strings // Initialize FdsConfig so FdsMfr / FdsMfrClient can resolve connection strings
fds.FdsConfig.Initialize(builder.Configuration); fds.FdsConfig.Initialize(builder.Configuration);
@@ -115,8 +138,22 @@ public class Program
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
// Communication service (email + SMS via ProcessWeb Mailer API) // Communication service (email + SMS via ProcessWeb Mailer API). Transport is the OCORE_web
// PWMailer client (ADR 0029); its options are mapped from the existing Fuchs:Mailer section
// rather than a second config section, so the Key Vault-managed Fuchs:Mailer:Token keeps working
// (secret Fuchs--Mailer--Token also named as the on-demand fallback for the placeholder case).
builder.Services.Configure<ProcessWebComSettings>(builder.Configuration.GetSection("Fuchs:Mailer")); builder.Services.Configure<ProcessWebComSettings>(builder.Configuration.GetSection("Fuchs:Mailer"));
builder.Services.AddOptions<PWMailerClientOptions>()
.Configure<IOptions<ProcessWebComSettings>>((o, s) =>
{
o.BaseUrl = s.Value.BaseUrl.TrimEnd('/') + "/api/mailer";
o.Account = s.Value.AccountId;
o.Token = s.Value.Token;
o.TokenSecretName = "Fuchs--Mailer--Token";
o.ServerId = s.Value.ServerId;
o.UserAgent = "Fuchs Intranet";
});
builder.Services.AddPWMailerClient<FuchsMailerClient>();
// Dev/test safety net: Fuchs:Email:OverrideRecipient redirects every outbound email // Dev/test safety net: Fuchs:Email:OverrideRecipient redirects every outbound email
// (see appsettings.Development.json) so real tenant-owners/end-customers are never emailed. // (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<FuchsEmailSettings>(builder.Configuration.GetSection("Fuchs:Email"));
@@ -127,7 +164,6 @@ public class Program
builder.Services.AddSingleton<IERechnungValidator, ProcessWebERechnungValidator>(); // external EN16931 + PDF/A-3 check builder.Services.AddSingleton<IERechnungValidator, ProcessWebERechnungValidator>(); // external EN16931 + PDF/A-3 check
builder.Services.AddHttpClient(ProcessWebERechnungValidator.HttpClientName, builder.Services.AddHttpClient(ProcessWebERechnungValidator.HttpClientName,
c => c.Timeout = TimeSpan.FromSeconds(90)); c => c.Timeout = TimeSpan.FromSeconds(90));
builder.Services.AddHttpClient("ProcessWebMailer");
// Fetches invoice-editor images (mfr:// picture links) for inline PDF rendering; whitelist- // Fetches invoice-editor images (mfr:// picture links) for inline PDF rendering; whitelist-
// restricted to the mfr portal (see InvoiceService.AllowedInlineImagePrefix / ADR 0018). // restricted to the mfr portal (see InvoiceService.AllowedInlineImagePrefix / ADR 0018).
builder.Services.AddHttpClient(InvoiceService.InlineImageHttpClientName, builder.Services.AddHttpClient(InvoiceService.InlineImageHttpClientName,
@@ -145,6 +181,7 @@ public class Program
builder.Services.AddScoped<IWidgetService, FuchsWidgetService>(); builder.Services.AddScoped<IWidgetService, FuchsWidgetService>();
builder.Services.AddScoped<IReportService, FuchsReportService>(); builder.Services.AddScoped<IReportService, FuchsReportService>();
builder.Services.AddScoped<IInvoiceService, InvoiceService>(); builder.Services.AddScoped<IInvoiceService, InvoiceService>();
builder.Services.AddScoped<IInvoiceFileBackupService, InvoiceFileBackupService>();
builder.Services.AddScoped<IReminderService, ReminderService>(); builder.Services.AddScoped<IReminderService, ReminderService>();
builder.Services.AddScoped<IEventService, EventService>(); builder.Services.AddScoped<IEventService, EventService>();
@@ -215,6 +252,38 @@ public class Program
// calls silently no-op instead of reaching Debug output / ErrorLog.txt. // calls silently no-op instead of reaching Debug output / ErrorLog.txt.
OCORE.Logging.Logger = app.Logger; OCORE.Logging.Logger = app.Logger;
// Exceptions that never pass through a request (Do()'s catch-all, PdfPreviewAsync,
// UseExceptionHandler) or a hosted service's own try/catch — a faulted fire-and-forget
// Task nobody awaited, or a crash on a non-request thread — are otherwise visible only
// as a debugger notification or a process exit: they never reach ILogger and therefore
// never reach fds__debuglog (ADR 0024). Route them through the app logger so the SQL
// sink and ErrorLog.txt both see them. On a terminating crash the fire-and-forget SQL
// write may not complete; the synchronous file sink (0003) is the record then.
TaskScheduler.UnobservedTaskException += (_, e) =>
{
app.Logger.LogError(new EventId(0, "Unobserved task exception"), e.Exception,
"Unobserved task exception: {Message}", e.Exception.GetBaseException().Message);
e.SetObserved();
};
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
var ex = e.ExceptionObject as Exception;
app.Logger.LogCritical(new EventId(0, "Unhandled exception"), ex,
"Unhandled exception on a non-request thread (terminating={IsTerminating}): {Message}",
e.IsTerminating, ex?.Message ?? e.ExceptionObject?.ToString());
};
// One deliberate Critical entry per application start. Critical is the one level no
// provider filters (file sink, console, SQL sink at Warning+), so a fds__debuglog row
// with CodeReference 'Application started' proves on every restart that the whole
// log pipeline (ADR 0024) is working end to end — and its absence is itself the alarm.
// Registered on ApplicationStarted so it is written once the host and every sink are up.
app.Lifetime.ApplicationStarted.Register(() =>
app.Logger.LogCritical(new EventId(0, "Application started"),
"Fuchs started: version={Version} environment={Environment} machine={Machine} pid={Pid}",
typeof(Program).Assembly.GetName().Version?.ToString() ?? "?",
app.Environment.EnvironmentName, Environment.MachineName, Environment.ProcessId));
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())
{ {
app.UseExceptionHandler("/error"); app.UseExceptionHandler("/error");
@@ -238,6 +307,9 @@ public class Program
app.UseRouting(); app.UseRouting();
app.UseAuthentication(); app.UseAuthentication();
// After authentication (needs the user claim) and before authorization/dispatch — see
// RequestLogScopeMiddleware for what this attributes every log line in the request to.
app.UseMiddleware<RequestLogScopeMiddleware>();
app.UseAuthorization(); app.UseAuthorization();
app.MapHub<NotificationHub>("/notifications"); app.MapHub<NotificationHub>("/notifications");
app.MapHub<DraftPreviewHub>("/draftpreview"); app.MapHub<DraftPreviewHub>("/draftpreview");
+65
View File
@@ -68,6 +68,60 @@ public class AzureBlobStorageService : IBlobStorageService
byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default) byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default)
=> UploadAsync(category, containerName, documentId, fileName, content, sourceRow, cancellationToken); => UploadAsync(category, containerName, documentId, fileName, content, sourceRow, cancellationToken);
public Task<Uri?> CopyInvoicePdfToBackupAsync(string invoiceId, string fileName,
CancellationToken cancellationToken = default)
=> CopyToBackupAsync("invoice", _settings.InvoiceContainer, invoiceId, fileName, cancellationToken);
private async Task<Uri?> CopyToBackupAsync(string category, string containerName, string documentId,
string fileName, CancellationToken cancellationToken)
{
if (_client == null) return null;
string blobName = BuildBlobName(documentId, fileName);
using var act = FuchsTelemetry.StartActivity("blobstorage.copytobackup");
act?.SetTag("fuchs.blobstorage.category", category);
act?.SetTag("fuchs.blobstorage.id", documentId);
try
{
var containerClient = _client.GetBlobContainerClient(containerName);
var sourceBlob = containerClient.GetBlobClient(blobName);
Response<bool> exists = await sourceBlob.ExistsAsync(cancellationToken);
if (!exists.Value)
{
_logger.LogDebug(
"Blob backup-copy skipped for {Category} {Id} — no existing blob at {Blob}.",
category, documentId, blobName);
return null;
}
string backupBlobFileName = string.IsNullOrWhiteSpace(fileName) ? $"{documentId}.pdf" : fileName;
string backupName = $"{documentId}/backup_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{backupBlobFileName}";
var backupBlob = containerClient.GetBlobClient(backupName);
// Server-side copy: the bytes never pass through this process. A private source in the
// same storage account is authorised by the request's own Shared Key credential, so no
// SAS is needed. WaitForCompletionAsync throws if the copy ends in a failed/aborted state.
// The original is deliberately left in place — it stays the "current" archive until the
// next regeneration overwrites it, and nothing can be half-renamed.
CopyFromUriOperation copy = await backupBlob.StartCopyFromUriAsync(
sourceBlob.Uri, (BlobCopyFromUriOptions?)null, cancellationToken);
await copy.WaitForCompletionAsync(cancellationToken);
FuchsTelemetry.BlobBackupCopiesSucceeded.Add(1, new KeyValuePair<string, object?>("category", category));
_logger.LogInformation("Copied {Category} {Id} blob {Old} to backup {New}.",
category, documentId, blobName, backupName);
return backupBlob.Uri;
}
catch (Exception ex)
{
FuchsTelemetry.BlobBackupCopiesFailed.Add(1, new KeyValuePair<string, object?>("category", category));
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "Blob backup-copy failed for {Category} {Id} ({Blob}).",
category, documentId, blobName);
return null;
}
}
public async Task<bool> ExistsAsync(string containerName, string documentId, string fileName, public async Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
@@ -81,6 +135,12 @@ public class AzureBlobStorageService : IBlobStorageService
Response<bool> response = await blobClient.ExistsAsync(cancellationToken); Response<bool> response = await blobClient.ExistsAsync(cancellationToken);
return response.Value; return response.Value;
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Caller cancelled (e.g. app shutdown during the startup backfill) — not a storage failure;
// reporting "not archived" here made the backfill go on to a doomed upload logged as an error.
throw;
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Blob existence check failed for {Container}/{Blob} — treating as not archived.", _logger.LogWarning(ex, "Blob existence check failed for {Container}/{Blob} — treating as not archived.",
@@ -216,6 +276,11 @@ public class AzureBlobStorageService : IBlobStorageService
category, documentId, containerName, blobName); category, documentId, containerName, blobName);
return blobClient.Uri; return blobClient.Uri;
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger.LogDebug("Blob upload cancelled for {Category} {Id} (caller cancelled).", category, documentId);
throw;
}
catch (Exception ex) catch (Exception ex)
{ {
FuchsTelemetry.BlobUploadsFailed.Add(1, new KeyValuePair<string, object?>("category", category)); FuchsTelemetry.BlobUploadsFailed.Add(1, new KeyValuePair<string, object?>("category", category));
+13
View File
@@ -0,0 +1,13 @@
namespace Fuchs.Services;
/// <summary>
/// Outcome of a draft flush ("Zwischenspeichern", <see cref="IInvoiceDraftService.FlushToDbAsync"/> /
/// <see cref="IReminderDraftService.FlushToDbAsync"/>). The draft service alone decides whether a
/// flush creates a DB row or updates the one an earlier flush created — flushes of one session are
/// serialised, so a save that overlaps the first registration of a new draft becomes an update — and
/// reports that decision here so the caller publishes the matching domain event instead of guessing
/// from the session state it saw before the flush.
/// </summary>
/// <param name="Data">The registered data; its <c>Id</c> is empty when the registration failed.</param>
/// <param name="Updated"><c>true</c> when the registration ran as an update of an existing DB draft, <c>false</c> for a create.</param>
public sealed record DraftFlushResult<T>(T Data, bool Updated) where T : class;
+1 -7
View File
@@ -259,13 +259,7 @@ public static class ERechnungMapper
} }
// ── helpers ──────────────────────────────────────────────────────────────── // ── helpers ────────────────────────────────────────────────────────────────
private static DateOnly IssueDate(GenericObjectDictionary? reg) private static DateOnly IssueDate(GenericObjectDictionary? reg) => InvoiceDates.IssueDate(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> /// <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) internal static CountryCode NormalizeCountry(string? raw)
+34
View File
@@ -0,0 +1,34 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OCORE.web;
using OCORE.web.pwmailer;
namespace Fuchs.Services;
/// <summary>
/// The OCORE_web <see cref="PWMailerClientService"/> with Fuchs' pre-validation: on top of the
/// contract rules the base applies, every outbound message is checked against the Mailer's
/// attachment limits (<see cref="MailerAttachmentPolicy"/>, configured under
/// <c>Fuchs:Mailer:Attachments</c>) before any bytes are sent. Registered as the application's
/// <see cref="IPWMailerClientService"/> in <c>Program.cs</c>; options are mapped from
/// <c>Fuchs:Mailer</c> (<see cref="ProcessWebComSettings"/>) there.
/// </summary>
public class FuchsMailerClient : PWMailerClientService
{
private readonly MailerAttachmentLimits _limits;
public FuchsMailerClient(IOptions<PWMailerClientOptions> options, IOptions<ProcessWebComSettings> settings,
IHttpClientFactory httpClientFactory, ILogger<PWMailerClientService> logger,
IOCORE_web_securityservice? securityService = null)
: base(options, httpClientFactory, logger, securityService)
{
_limits = settings.Value.Attachments;
}
protected override IReadOnlyList<string> ValidateMessage(PWMailerMessage message)
{
var problems = new List<string>(base.ValidateMessage(message));
problems.AddRange(MailerAttachmentPolicy.Validate(message.Attachments, _limits));
return problems;
}
}
+14 -2
View File
@@ -12,7 +12,8 @@ public interface IBlobStorageService
/// supplied (typically <c>FdsInvoiceData.InvoiceRegistration</c>), blob metadata is projected from /// supplied (typically <c>FdsInvoiceData.InvoiceRegistration</c>), blob metadata is projected from
/// it using <see cref="AzureBlobStorageSettings.MetadataFields"/> — see <see cref="DocumentMetadataBuilder"/>. /// it using <see cref="AzureBlobStorageSettings.MetadataFields"/> — see <see cref="DocumentMetadataBuilder"/>.
/// Returns the blob URI, or <c>null</c> when storage is disabled/unconfigured /// Returns the blob URI, or <c>null</c> when storage is disabled/unconfigured
/// or the upload failed — failures never break the primary DB-storage flow. /// or the upload failed — failures never break the primary DB-storage flow. Only a cancelled
/// <paramref name="cancellationToken"/> surfaces, as <see cref="OperationCanceledException"/>.
/// </summary> /// </summary>
Task<Uri?> UploadInvoicePdfAsync(string invoiceId, string fileName, byte[] content, Task<Uri?> UploadInvoicePdfAsync(string invoiceId, string fileName, byte[] content,
IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default); IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
@@ -37,10 +38,21 @@ public interface IBlobStorageService
Task<Uri?> UploadDocumentAsync(string category, string containerName, string documentId, string fileName, Task<Uri?> UploadDocumentAsync(string category, string containerName, string documentId, string fileName,
byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default); byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
/// <summary>
/// Server-side copies an existing invoice PDF blob to a timestamped backup name in the same
/// container; the original blob is left in place. Called before an invoice's stored file is
/// cleared: the next regenerate-on-read (ADR 0025) uploads under the original blob name with
/// <c>overwrite: true</c>, and the backup copy is what survives that. Returns the backup
/// blob's URI, or <c>null</c> when there was nothing to back up, storage is disabled/unconfigured,
/// or the copy failed — logged, never throws.
/// </summary>
Task<Uri?> CopyInvoicePdfToBackupAsync(string invoiceId, string fileName, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Returns <c>true</c> if a blob already exists for the given container/document/filename /// Returns <c>true</c> if a blob already exists for the given container/document/filename
/// combination. Used by the startup backfill to skip documents that were already archived. /// combination. Used by the startup backfill to skip documents that were already archived.
/// Returns <c>false</c> (never throws) when storage is disabled/unconfigured or the check fails. /// Returns <c>false</c> when storage is disabled/unconfigured or the check fails; throws only
/// <see cref="OperationCanceledException"/> when <paramref name="cancellationToken"/> is cancelled.
/// </summary> /// </summary>
Task<bool> ExistsAsync(string containerName, string documentId, string fileName, Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
+12 -4
View File
@@ -2,7 +2,9 @@
/// <summary> /// <summary>
/// Abstraction for outbound communication: email and SMS. /// Abstraction for outbound communication: email and SMS.
/// Backed by the ProcessWeb Mailer API (POST {BaseUrl}/mailer/push_com). /// Backed by the ProcessWeb Mailer API (POST {BaseUrl}/api/mailer/push_com); contract at
/// https://processweb.de/api/mailer/fc, client-side description in
/// Docs/Concepts/processweb-mailer-interface.md.
/// </summary> /// </summary>
public interface IComService public interface IComService
{ {
@@ -14,9 +16,15 @@ public interface IComService
/// <param name="html">HTML body content (signature is appended automatically).</param> /// <param name="html">HTML body content (signature is appended automatically).</param>
/// <param name="email">Recipient email address.</param> /// <param name="email">Recipient email address.</param>
/// <param name="name">Recipient display name.</param> /// <param name="name">Recipient display name.</param>
/// <param name="attachments">Optional file attachments (filename → content).</param> /// <param name="attachments">
/// Optional file attachments (filename → content). Validated against the Mailer's limits
/// (<see cref="MailerAttachmentPolicy"/>) before sending; any violation fails the send.
/// </param>
/// <param name="cc">Optional Cc recipient (e.g. a customer's "Zusatzempfänger").</param> /// <param name="cc">Optional Cc recipient (e.g. a customer's "Zusatzempfänger").</param>
/// <returns><c>true</c> when the email was accepted successfully.</returns> /// <returns>
/// <c>true</c> only when the Mailer accepted the message <em>and</em> confirmed that every
/// attachment was processed (<c>attachmentsProcessed</c> equals the number sent).
/// </returns>
Task<bool> SendEmailAsync(string reference, string subject, string html, Task<bool> SendEmailAsync(string reference, string subject, string html,
string email, string name, Dictionary<string, byte[]>? attachments = null, string? cc = null); string email, string name, Dictionary<string, byte[]>? attachments = null, string? cc = null);
@@ -30,7 +38,7 @@ public interface IComService
/// <summary> /// <summary>
/// Verifies the configured account/token against the Mailer API's credential-check /// Verifies the configured account/token against the Mailer API's credential-check
/// endpoint (GET {BaseUrl}/mailer/fc?fn=verify) without sending anything. /// endpoint (GET {BaseUrl}/api/mailer/verify) without sending anything.
/// </summary> /// </summary>
Task<MailerCredentialsCheckResult> VerifyCredentialsAsync(CancellationToken cancellationToken = default); Task<MailerCredentialsCheckResult> VerifyCredentialsAsync(CancellationToken cancellationToken = default);
} }
+6 -3
View File
@@ -45,10 +45,13 @@ public interface IInvoiceDraftService
/// <summary> /// <summary>
/// Persists the cached session to the DB via the existing invoice registration path /// Persists the cached session to the DB via the existing invoice registration path
/// ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success. /// ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success. Flushes of one
/// Returns the registered invoice data (for the success event), or null if the token is unknown. /// session are serialised (<see cref="InvoiceDraftSession.FlushGate"/>): an overlapping save of a
/// not-yet-registered draft waits for the first registration and then runs as an update, so a
/// double-click never creates two DB rows. Returns the registered data plus whether the flush
/// ran as an update (for the success event), or null if the token is unknown.
/// </summary> /// </summary>
Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec); Task<DraftFlushResult<FdsInvoiceData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary> /// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
Task<Document?> RenderPreviewAsync(string token); Task<Document?> RenderPreviewAsync(string token);
@@ -0,0 +1,32 @@
using OCORE.security;
namespace Fuchs.Services;
/// <summary>
/// Admin-only: backs up an invoice's stored PDF and clears it so the document regenerates on the
/// next read (ADR 0025). See Docs/Decisions/0027-admin-invoice-file-backup-and-clear.md for why
/// this is a dedicated service/proc rather than reusing the manual-only
/// <c>_BackupAndClearInvoiceFile</c> maintenance tool.
/// </summary>
public interface IInvoiceFileBackupService
{
/// <summary>
/// Backs up the invoice's current stored file (DB row via
/// <c>fds__admin_backupAndClearInvoiceFile</c>, plus a best-effort rename-to-backup of the
/// archived blob) and clears the stored file.
/// </summary>
Task<InvoiceFileBackupResult> BackupAndClearAsync(string id, string userAccountId, DatabaseSecurity dbSec,
CancellationToken cancellationToken = default);
}
/// <summary>Outcome of <see cref="IInvoiceFileBackupService.BackupAndClearAsync"/>.</summary>
public sealed class InvoiceFileBackupResult
{
public bool Success { get; init; }
/// <summary>Machine-readable reason when <see cref="Success"/> is <c>false</c>.</summary>
public string? Reason { get; init; }
/// <summary>The archived blob's backup URI, when the blob rename succeeded (best-effort — can be <c>null</c> even on success).</summary>
public Uri? BlobBackupUri { get; init; }
}
+6 -3
View File
@@ -43,10 +43,13 @@ public interface IReminderDraftService
/// <summary> /// <summary>
/// Persists the cached session to the DB via the existing reminder registration path /// Persists the cached session to the DB via the existing reminder registration path
/// ("Zwischenspeichern"). Sets <see cref="ReminderDraftSession.RemId"/> on success. /// ("Zwischenspeichern"). Sets <see cref="ReminderDraftSession.RemId"/> on success. Flushes of one
/// Returns the registered reminder data (for the success event), or null if the token is unknown. /// session are serialised (<see cref="ReminderDraftSession.FlushGate"/>): an overlapping save of a
/// not-yet-registered draft waits for the first registration and then runs as an update, so a
/// double-click never creates two DB rows. Returns the registered data plus whether the flush
/// ran as an update (for the success event), or null if the token is unknown.
/// </summary> /// </summary>
Task<FdsReminderData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec); Task<DraftFlushResult<FdsReminderData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary> /// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
Document? RenderPreview(string token); Document? RenderPreview(string token);
+6 -2
View File
@@ -22,7 +22,11 @@ public interface ISystemStatusService
/// <summary> /// <summary>
/// Sends a test email through the normal <see cref="IComService"/> pipeline, so the /// Sends a test email through the normal <see cref="IComService"/> pipeline, so the
/// dev/test recipient override (<see cref="FuchsEmailSettings.OverrideRecipient"/>) applies /// dev/test recipient override (<see cref="FuchsEmailSettings.OverrideRecipient"/>) applies
/// exactly as for any other outbound email. /// exactly as for any other outbound email. With <paramref name="attachPdf"/> (the default)
/// a small letter-layout PDF rendered from the test mail's subject/body is attached, so the
/// Mailer's attachment path (multipart + <c>attachmentsProcessed</c> confirmation, ADR 0028)
/// is exercised too; <c>false</c> sends a plain text-only mail.
/// </summary> /// </summary>
Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body, CancellationToken cancellationToken = default); Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body,
bool attachPdf = true, CancellationToken cancellationToken = default);
} }
+94 -55
View File
@@ -60,27 +60,32 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
var session = _cache.Get(token); var session = _cache.Get(token);
if (session == null) return null; if (session == null) return null;
string oldValue = "", newValue = ""; // The editor fires patches (and state/preview fetches) concurrently for one token; without
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue); // the lock a block.replace on one request mutates Req while another enumerates it.
if (!mutated) lock (session.SyncRoot)
{ {
_logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref); string oldValue = "", newValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
if (!mutated)
{
_logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
return session;
}
Refresh(session);
session.Version++;
session.History.Add(new ChangeHistoryEntry
{
UserAccountId = session.UserAccountId,
Target = delta.Target,
Ref = delta.Ref,
OldValue = oldValue,
NewValue = newValue,
Version = session.Version
});
_cache.Set(session);
return session; return session;
} }
Refresh(session);
session.Version++;
session.History.Add(new ChangeHistoryEntry
{
UserAccountId = session.UserAccountId,
Target = delta.Target,
Ref = delta.Ref,
OldValue = oldValue,
NewValue = newValue,
Version = session.Version
});
_cache.Set(session);
return session;
} }
/// <summary> /// <summary>
@@ -424,33 +429,40 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
} }
// ── View state / history ──────────────────────────────────────────────── // ── View state / history ────────────────────────────────────────────────
/// <remarks>
/// The result is serialised by the caller after this returns, so everything mutable is a
/// snapshot taken under the session lock — never a live reference into the session.
/// </remarks>
public object BuildState(InvoiceDraftSession session) public object BuildState(InvoiceDraftSession session)
{ {
session.Touch(); session.Touch();
return new lock (session.SyncRoot)
{ {
token = session.Token, return new
version = session.Version,
invid = session.InvId,
isDraft = session.IsDraft,
admin = session.Admin,
@new = session.New,
req = session.Req,
sums = new
{ {
total_net = session.Sums.TotalNet, token = session.Token,
total_gross = session.Sums.TotalGross, version = session.Version,
total_vat = session.Sums.TotalVat, invid = session.InvId,
service_net = session.Sums.ServiceNet, isDraft = session.IsDraft,
service_vat = session.Sums.ServiceVat, admin = session.Admin.DeepClone(),
vat = session.Sums.VatByRate, @new = session.New.DeepClone(),
block_net = session.Sums.NetByBlock req = session.Req.DeepClone(),
}, sums = new
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }), {
historyCount = session.History.Count, total_net = session.Sums.TotalNet,
notes = BuildNotes(session), total_gross = session.Sums.TotalGross,
setDisplay = BuildSetDisplay(session) total_vat = session.Sums.TotalVat,
}; service_net = session.Sums.ServiceNet,
service_vat = session.Sums.ServiceVat,
vat = new Dictionary<string, decimal>(session.Sums.VatByRate),
block_net = new Dictionary<string, decimal>(session.Sums.NetByBlock)
},
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }).ToList(),
historyCount = session.History.Count,
notes = BuildNotes(session).ToList(),
setDisplay = BuildSetDisplay(session)
};
}
} }
/// <summary> /// <summary>
@@ -497,34 +509,60 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
.Select(n => new { text = n.Text, style = n.Style }); .Select(n => new { text = n.Text, style = n.Style });
} }
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) => public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token)
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>(); {
var session = _cache.Get(token);
if (session == null) return Array.Empty<ChangeHistoryEntry>();
lock (session.SyncRoot) return session.History.ToList();
}
// ── Flush / preview ──────────────────────────────────────────────────────── // ── Flush / preview ────────────────────────────────────────────────────────
public async Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec) public async Task<DraftFlushResult<FdsInvoiceData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
{ {
var session = _cache.Get(token); var session = _cache.Get(token);
if (session == null) return null; if (session == null) return null;
var fds = BuildFdsData(session); // One flush at a time per session: the create/update decision below is read-then-write across
bool change = !string.IsNullOrEmpty(session.InvId); // an await, so two overlapping saves of a new draft would both see an empty InvId and both
var reg = await _invoices.RegisterInvoiceAsync(fds, change, session.InvId, userAccountId, dbSec); // call fds__createInvoice. The second save waits here, then runs as an update of the row the
if (!string.IsNullOrEmpty(reg.Id)) // first one created (with its own, newer snapshot).
await session.FlushGate.WaitAsync();
try
{ {
session.InvId = reg.Id; FdsInvoiceData fds;
_cache.Set(session); string invId;
_logger.LogInformation("Draft {Token} flushed to DB invoice {InvId} (change={Change}, user={User})", lock (session.SyncRoot)
token, reg.Id, change, userAccountId); {
fds = BuildFdsData(session); // deep-cloned snapshot — the await below runs outside the lock
invId = session.InvId;
}
bool change = !string.IsNullOrEmpty(invId);
var reg = await _invoices.RegisterInvoiceAsync(fds, change, invId, userAccountId, dbSec);
if (!string.IsNullOrEmpty(reg.Id))
{
lock (session.SyncRoot) session.InvId = reg.Id;
_cache.Set(session);
_logger.LogInformation("Draft {Token} flushed to DB invoice {InvId} (change={Change}, user={User})",
token, reg.Id, change, userAccountId);
}
return new DraftFlushResult<FdsInvoiceData>(reg, change);
}
finally
{
session.FlushGate.Release();
} }
return reg;
} }
public async Task<Document?> RenderPreviewAsync(string token) public async Task<Document?> RenderPreviewAsync(string token)
{ {
var session = _cache.Get(token); var session = _cache.Get(token);
if (session == null) return null; if (session == null) return null;
var fds = BuildFdsData(session); FdsInvoiceData fds;
fds.InvoiceRegistration = SynthesizeRegistration(session); lock (session.SyncRoot)
{
fds = BuildFdsData(session);
fds.InvoiceRegistration = SynthesizeRegistration(session);
}
fds.IsDraft = true; fds.IsDraft = true;
return await _invoices.GenerateInvoicePdfAsync(fds, draft: true); return await _invoices.GenerateInvoicePdfAsync(fds, draft: true);
} }
@@ -616,7 +654,8 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
["SendToAddressJson"] = InvoiceRecipientAddress.FromCustomValues(Str(session.New["CustomValues"]))? ["SendToAddressJson"] = InvoiceRecipientAddress.FromCustomValues(Str(session.New["CustomValues"]))?
.ToJson().ToString(Newtonsoft.Json.Formatting.None) ?? "", .ToJson().ToString(Newtonsoft.Json.Formatting.None) ?? "",
["InvoiceOptions"] = BuildInvoiceOptions(session), ["InvoiceOptions"] = BuildInvoiceOptions(session),
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) // UTC like the stored column — InvoiceDates.IssueDate converts it to the German day
["DateCreated"] = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
}; };
int idx = 0; int idx = 0;
+131
View File
@@ -0,0 +1,131 @@
using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using OCORE.security;
using OCORE.SQL;
using static OCORE.commons;
using static OCORE.SQL.sql;
namespace Fuchs.Services;
/// <summary>See <see cref="IInvoiceFileBackupService"/>.</summary>
public class InvoiceFileBackupService : IInvoiceFileBackupService
{
private static readonly EventId BackupClearEvent = new(0, "AdminBackupClearInvoiceFile");
private readonly Fuchs_intranet _intranet;
private readonly IInvoiceService _invoices;
private readonly IBlobStorageService _blobStorage;
private readonly ILogger<InvoiceFileBackupService> _logger;
public InvoiceFileBackupService(Fuchs_intranet intranet, IInvoiceService invoices,
IBlobStorageService blobStorage, ILogger<InvoiceFileBackupService> logger)
{
_intranet = intranet;
_invoices = invoices;
_blobStorage = blobStorage;
_logger = logger;
}
private string Conn => _intranet.Intranet__SQLConnectionString;
public async Task<InvoiceFileBackupResult> BackupAndClearAsync(string id, string userAccountId,
DatabaseSecurity dbSec, CancellationToken cancellationToken = default)
{
var invoice = await _invoices.LoadInvoiceAsync(id, userAccountId, dbSec);
// LoadInvoiceAsync always yields a registration (an empty dictionary for an unknown id),
// so "not found" is an empty Id, never a null registration.
if (invoice.InvoiceRegistration == null || string.IsNullOrEmpty(invoice.Id))
{
_logger.LogWarning(BackupClearEvent,
"BackupAndClearAsync: invoice {Id} not found, user={User}", id, userAccountId);
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "invoice_not_found"));
return new InvoiceFileBackupResult { Success = false, Reason = "invoice_not_found" };
}
// Same precondition as regenerate-on-read (ADR 0025): only a finalised, non-external
// invoice may have its file cleared. Clearing a draft's file would let the next read
// persist a draft render through fds__setInvoiceFile (WHERE [file] IS NULL), which then
// permanently blocks the real final PDF; an external (MFR) document has no template to
// regenerate from, so clearing it would just leave a permanent 404.
bool isExternal = invoice.InvoiceRegistration.getItem("IsExternal", false) is true;
if (invoice.IsDraft || isExternal)
{
string reason = invoice.IsDraft ? "not_final" : "external";
_logger.LogWarning(BackupClearEvent,
"BackupAndClearAsync: refused for {Id} ({Reason}) — only finalised, non-external invoices can be cleared, user={User}",
invoice.Id, reason, userAccountId);
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", reason));
return new InvoiceFileBackupResult { Success = false, Reason = reason };
}
// fds__getInvoice reports hasFile; without a stored file there is nothing to back up or
// clear, so stop here — before the blob copy — rather than leaving a spare backup_* blob
// behind on every repeated click on an already-cleared invoice. The proc re-checks
// [file] IS NOT NULL itself (OUTPUT row), so a race between this read and the clear is
// still reported as no_file, just with one extra blob copy.
if (invoice.InvoiceRegistration.getItem("hasFile", false) is not true)
{
_logger.LogWarning(BackupClearEvent,
"BackupAndClearAsync: no file stored for {Id} — nothing to back up or clear, user={User}",
invoice.Id, userAccountId);
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "no_file"));
return new InvoiceFileBackupResult { Success = false, Reason = "no_file" };
}
string fileName = invoice.InvoiceRegistration.getString("DocumentName").ne($"Rechnung_{invoice.Id}.pdf");
// Copy (not move) the archived blob to a backup name BEFORE the DB clear and leave the
// original in place: the backup therefore exists before any regenerate-on-read (ADR 0025)
// could overwrite the original name, and a DB failure/no-op below leaves the canonical
// archive blob untouched (only a spare copy remains — harmless). Best-effort: a failure
// here is logged, the DB backup row remains the authoritative copy.
Uri? blobBackupUri;
try
{
blobBackupUri = await _blobStorage.CopyInvoicePdfToBackupAsync(invoice.Id, fileName, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(BackupClearEvent, ex,
"BackupAndClearAsync: blob backup-copy threw for {Id}, user={User}", invoice.Id, userAccountId);
blobBackupUri = null;
}
var pl = new List<SqlParameter>
{
SQL_VarChar("@authuser", userAccountId),
SQL_VarChar("@Id", invoice.Id),
};
// fds__admin_backupAndClearInvoiceFile writes WHERE [file] IS NOT NULL and returns the
// cleared Id via its OUTPUT clause, so an empty result set means nothing was backed
// up/cleared (invoice missing, or file already empty) — rows-affected can't be used for
// this because the proc runs SET NOCOUNT ON (same reasoning as fds__setInvoiceFile, ADR 0025).
var cleared = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__admin_backupAndClearInvoiceFile] @authuser, @Id;",
Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(cleared.Exception))
{
_logger.LogError(BackupClearEvent,
"BackupAndClearAsync: fds__admin_backupAndClearInvoiceFile failed for {Id}: {Error}, user={User}",
invoice.Id, cleared.Exception, userAccountId);
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "db_error"));
return new InvoiceFileBackupResult { Success = false, Reason = "db_error", BlobBackupUri = blobBackupUri };
}
if (cleared.Count == 0)
{
_logger.LogWarning(BackupClearEvent,
"BackupAndClearAsync: no file to back up/clear for {Id} (already empty or invoice missing), user={User}",
invoice.Id, userAccountId);
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "no_file"));
return new InvoiceFileBackupResult { Success = false, Reason = "no_file", BlobBackupUri = blobBackupUri };
}
_logger.LogWarning(BackupClearEvent,
"Admin backed up and cleared invoice file for {Id}, blobBackup={BlobBackupUri}, user={User}",
invoice.Id, blobBackupUri, userAccountId);
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "cleared"));
return new InvoiceFileBackupResult { Success = true, BlobBackupUri = blobBackupUri };
}
}
+98 -23
View File
@@ -70,6 +70,8 @@ public class InvoiceService : IInvoiceService
private string Conn => _intranet.Intranet__SQLConnectionString; private string Conn => _intranet.Intranet__SQLConnectionString;
private static readonly EventId InvoiceAmountRejected = new(0, "InvoiceAmountRejected");
public async Task<FdsInvoiceData> LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec) public async Task<FdsInvoiceData> LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec)
{ {
_logger.LogDebug("LoadInvoiceAsync id={Id} user={User}", id, userAccountId); _logger.LogDebug("LoadInvoiceAsync id={Id} user={User}", id, userAccountId);
@@ -151,6 +153,27 @@ public class InvoiceService : IInvoiceService
return blocks; return blocks;
} }
/// <summary>
/// The header save batch for <see cref="RegisterInvoiceAsync"/>. If the create/set proc fails
/// (e.g. a parameter conversion error) the batch would otherwise carry on with a NULL
/// <c>@Id</c> and fail again in <c>fds__createInvoice_Details</c> ("Cannot insert NULL into
/// InvId"), burying the real error; the guard stops the batch right after the first failure.
/// </summary>
internal static string BuildRegisterInvoiceSql(bool change, bool hasProvisionLocation)
{
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
sqlParts.Add(change
? "EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @SendToEmailCc, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;"
: "EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @SendToEmailCc, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
sqlParts.Add(change
? "IF @Id IS NULL THROW 50001, N'fds__setInvoice did not return an invoice id - invoice header not saved.', 1;"
: "IF @Id IS NULL THROW 50001, N'fds__createInvoice did not return an invoice id - invoice header not saved.', 1;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
if (hasProvisionLocation)
sqlParts.Add("UPDATE [dbo].[fds__invoices] SET [ProvisionLocation] = LEFT(@ProvisionLocation,1000) WHERE [Id] = @Id AND [isFinal] = 0;");
return string.Join("\n", sqlParts);
}
public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId, public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
string userAccountId, DatabaseSecurity dbSec) string userAccountId, DatabaseSecurity dbSec)
{ {
@@ -158,28 +181,26 @@ public class InvoiceService : IInvoiceService
if (invoice.NewValues == null) { _logger.LogWarning("RegisterInvoiceAsync: no form values supplied (user={User})", userAccountId); return invoice; } if (invoice.NewValues == null) { _logger.LogWarning("RegisterInvoiceAsync: no form values supplied (user={User})", userAccountId); return invoice; }
var pl = new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId) }; var pl = new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId) };
pl.AddRange(invoice.BuildInvoiceParams(change, invId)); try
{
pl.AddRange(invoice.BuildInvoiceParams(change, invId));
}
catch (InvoiceAmountOutOfRangeException ex)
{
// Refuse rather than store a wrong amount; an empty registration makes the caller report the save as failed.
_logger.LogError(InvoiceAmountRejected, ex,
"RegisterInvoiceAsync: refused invoice {InvId} - amount {Parameter} = {Value} is out of range (user={User})",
invId, ex.Parameter, ex.Value, userAccountId);
return invoice;
}
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" }; if (change) pl.Add(SQL_VarChar("@InvId", invId));
if (!change) bool hasProvisionLocation = invoice.RawProvisionLocation.Length > 0;
{ if (hasProvisionLocation)
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @SendToEmailCc, @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, @SendToEmailCc, @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)
{
pl.Add(SQL_NVarChar("@ProvisionLocation", pl.Add(SQL_NVarChar("@ProvisionLocation",
string.Join("\n", invoice.RawProvisionLocation).LeftToFirst("<!--", emptyIfNotFound: false))); string.Join("\n", invoice.RawProvisionLocation).LeftToFirst("<!--", emptyIfNotFound: false)));
sqlParts.Add("UPDATE [dbo].[fds__invoices] SET [ProvisionLocation] = LEFT(@ProvisionLocation,1000) WHERE [Id] = @Id AND [isFinal] = 0;");
}
var invdset = await getSQLDataSet_async(string.Join("\n", sqlParts), var invdset = await getSQLDataSet_async(BuildRegisterInvoiceSql(change, hasProvisionLocation),
Conn, pl, tablenames: new[] { "inv", "det" }, Conn, pl, tablenames: new[] { "inv", "det" },
Security: dbSec, options: new FIS_SQLOptions()); Security: dbSec, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(invdset.Exception)) if (!string.IsNullOrEmpty(invdset.Exception))
@@ -205,6 +226,17 @@ public class InvoiceService : IInvoiceService
/// The whole set is cleared and rewritten on every call — simpler and safer than diffing — /// The whole set is cleared and rewritten on every call — simpler and safer than diffing —
/// mirroring the legacy <c>fds__invoice_data.RegisterInvoice_V1</c> (VB) behaviour. /// mirroring the legacy <c>fds__invoice_data.RegisterInvoice_V1</c> (VB) behaviour.
/// </summary> /// </summary>
/// <summary>
/// Normalises an item's posted <c>ref</c> (the invoice a CanceledInvoice / IntInvoiceAllocation line
/// points at) for <c>fds__invoice_items.reference_InvId</c> (varchar(20)); null when absent or not an id.
/// </summary>
internal static string? ItemReferenceInvId(string? raw)
{
string s = (raw ?? "").Trim();
if (s.Length == 0 || s.Length > 20 || s.Equals("null", StringComparison.OrdinalIgnoreCase)) return null;
return s.All(char.IsAsciiLetterOrDigit) ? s : null;
}
private async Task PersistInvoiceLineItemsAsync(FdsInvoiceData invoice, string userAccountId, DatabaseSecurity dbSec) private async Task PersistInvoiceLineItemsAsync(FdsInvoiceData invoice, string userAccountId, DatabaseSecurity dbSec)
{ {
string invoiceId = invoice.Id; string invoiceId = invoice.Id;
@@ -269,6 +301,7 @@ public class InvoiceService : IInvoiceService
float vs = 0; float vs = 0;
if (TryCastSingle(itmDic.getItem("vs"), ref vs)) row["value_service"] = (decimal)vs; if (TryCastSingle(itmDic.getItem("vs"), ref vs)) row["value_service"] = (decimal)vs;
row["det"] = itmDic.no("det", false) is true; row["det"] = itmDic.no("det", false) is true;
if (ItemReferenceInvId(itmDic.nz("ref")) is { } refInvId) row["reference_InvId"] = refInvId;
row["SortOrder"] = (byte)Math.Clamp(itemSort, 0, 255); row["SortOrder"] = (byte)Math.Clamp(itemSort, 0, 255);
itemsTable.Rows.Add(row); itemsTable.Rows.Add(row);
} }
@@ -287,6 +320,22 @@ public class InvoiceService : IInvoiceService
} }
} }
/// <summary>
/// Puts the stored Leistungsdatum / -zeitraum into the PDF admin block: German dates (legacy ISO
/// values converted) and the label the editor shows — "Leistungsdatum" for one date,
/// "Leistungszeitraum" for a from–to range.
/// </summary>
internal static void ApplyProvisionPeriod(FuchsPdf.FdsTextBlocks tb, string? provisionPeriod)
{
tb.ProvisionPeriod = ProvisionPeriodForDisplay(provisionPeriod).Trim();
tb.AdminProvLabel = tb.ProvisionPeriod.Contains(" - ") || tb.ProvisionPeriod.Length == 0
? "Leistungszeitraum" : "Leistungsdatum";
}
/// <summary>Shows ISO dates (yyyy-MM-dd) saved by older editor versions as German dd.MM.yyyy.</summary>
internal static string ProvisionPeriodForDisplay(string? period) =>
System.Text.RegularExpressions.Regex.Replace(period ?? "", @"\b(\d{4})-(\d{2})-(\d{2})\b", "$3.$2.$1");
public async Task<Document> GenerateInvoicePdfAsync(FdsInvoiceData invoice, bool draft) public async Task<Document> GenerateInvoicePdfAsync(FdsInvoiceData invoice, bool draft)
{ {
using var act = FuchsTelemetry.StartActivity("invoice.render"); using var act = FuchsTelemetry.StartActivity("invoice.render");
@@ -302,8 +351,11 @@ public class InvoiceService : IInvoiceService
: Array.Empty<string>(), : Array.Empty<string>(),
AdminUser = reg?.getString("UserNameFinalized") ?? "", AdminUser = reg?.getString("UserNameFinalized") ?? "",
AdminUserEmail = reg?.getString("UserEmailFinalized") ?? "", AdminUserEmail = reg?.getString("UserEmailFinalized") ?? "",
AdminDatumValue = reg?.getString("DateCreated") is { Length: > 0 } dc ? DateTime.Parse(dc) : DateTime.Now // Rechnungsdatum = DateFinalized (incl. backdating), draft: DateCreated — same source as the
// e-invoice issue date (ERechnungMapper), see InvoiceDates / ADR 0032.
AdminDatumValue = InvoiceDates.IssueDate(reg).ToDateTime(TimeOnly.MinValue)
}; };
ApplyProvisionPeriod(tb, reg?.getString("ProvisionPeriod"));
var inlineImages = await ResolveInlineImagesAsync(invoice); var inlineImages = await ResolveInlineImagesAsync(invoice);
var doc = await _pdf.WriteLetterAsync(tb, draft); var doc = await _pdf.WriteLetterAsync(tb, draft);
doc.Info.Title = reg?.getString("InvoiceTitle") ?? ""; doc.Info.Title = reg?.getString("InvoiceTitle") ?? "";
@@ -426,8 +478,20 @@ public class InvoiceService : IInvoiceService
{ {
byte[] ba; byte[] ba;
try { ba = await RenderInvoicePdfBytesAsync(invoice, draft); } try { ba = await RenderInvoicePdfBytesAsync(invoice, draft); }
catch (Exception ex) { _logger.LogError(ex, "StoreInvoiceDocumentFileAsync render failed for {Id}", invoice.Id); ba = Array.Empty<byte>(); } catch (Exception ex)
if (ba.Length == 0) return Array.Empty<byte>(); {
_logger.LogError(ex, "StoreInvoiceDocumentFileAsync render failed for {Id}", invoice.Id);
return Array.Empty<byte>();
}
if (ba.Length == 0)
{
// Distinct from the catch above: the renderer returned normally but with no bytes, and
// callers only see the same empty array either way.
_logger.LogError(
"StoreInvoiceDocumentFileAsync: render for {Id} completed without an exception but produced 0 bytes, draft={Draft}, user={User}",
invoice.Id, draft, userAccountId);
return Array.Empty<byte>();
}
var pl = new List<SqlParameter> var pl = new List<SqlParameter>
{ {
@@ -435,10 +499,21 @@ public class InvoiceService : IInvoiceService
SQL_VarChar("@Id", invoice.Id), SQL_VarChar("@Id", invoice.Id),
new("@file", SqlDbType.VarBinary) { Value = ba } new("@file", SqlDbType.VarBinary) { Value = ba }
}; };
bool r = await setSQLValue_async( // fds__setInvoiceFile writes WHERE [file] IS NULL and returns the updated Id via its
// OUTPUT clause, so an empty result set means the UPDATE matched nothing (already stored,
// wrong id, invoice gone) without any SQL error. Rows-affected cannot be used for this:
// the proc runs SET NOCOUNT ON, so ExecuteNonQuery reports -1 either way (ADR 0025).
var stored = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__setInvoiceFile] @Id, @file;", "EXECUTE [dbo].[fds__setInvoiceFile] @Id, @file;",
Conn, pl, Security: dbSec, options: new FIS_SQLOptions()); Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
if (!r) return Array.Empty<byte>(); if (!string.IsNullOrEmpty(stored.Exception)) return Array.Empty<byte>();
if (stored.Count == 0)
{
_logger.LogError(
"StoreInvoiceDocumentFileAsync: fds__setInvoiceFile updated 0 rows for {Id} (file already stored, or invoice missing), user={User}",
invoice.Id, userAccountId);
return Array.Empty<byte>();
}
string fileName = invoice.InvoiceRegistration?.getString("DocumentName") string fileName = invoice.InvoiceRegistration?.getString("DocumentName")
.ne($"Rechnung_{invoice.Id}.pdf") ?? $"Rechnung_{invoice.Id}.pdf"; .ne($"Rechnung_{invoice.Id}.pdf") ?? $"Rechnung_{invoice.Id}.pdf";
+54
View File
@@ -0,0 +1,54 @@
using OCORE.web.pwmailer;
namespace Fuchs.Services;
/// <summary>
/// Fuchs-side pre-validation of email attachments against the ProcessWeb Mailer API contract
/// (https://processweb.de/api/mailer/fc, "Attachment Rules"): file count, per-file and per-message
/// size, and the server's forbidden-extension list. The rules the server enforces unconditionally
/// (empty file, missing extension, required fields) are already covered by
/// <see cref="PWMailerMessage.Validate"/>; this adds the limit rules on top — see
/// <see cref="FuchsMailerClient.ValidateMessage"/>. Pure — no I/O — so it is unit-testable.
/// </summary>
public static class MailerAttachmentPolicy
{
/// <summary>
/// Extensions the Mailer rejects (case-insensitive). The contract lists these explicitly and
/// states there are further ones; the server remains authoritative for anything not listed.
/// </summary>
internal static readonly HashSet<string> ForbiddenExtensions = new(StringComparer.OrdinalIgnoreCase)
{
"ade", "adp", "app", "application", "appx", "aspx", "bas", "bat", "cab", "cer", "chm", "cmd",
"com", "cpl", "crt", "csh", "dll", "drv", "exe", "fxp", "jar", "js", "jnlp", "msi", "ps1",
"psc1", "psd1", "py", "pyc", "reg", "scf", "scr", "sys", "vbs", "wsf", "xll",
};
/// <summary>Returns every limit rule the attachments violate, in a stable order; empty when acceptable.</summary>
public static IReadOnlyList<string> Validate(IReadOnlyList<PWMailerAttachment> attachments, MailerAttachmentLimits limits)
{
var violations = new List<string>();
if (attachments.Count == 0) return violations;
if (attachments.Count > limits.MaxCount)
violations.Add($"Too many attachments: {attachments.Count} (max {limits.MaxCount})");
long total = 0;
foreach (var a in attachments)
{
if (a == null) continue;
long size = a.Bytes?.LongLength ?? 0;
total += size;
if (size > limits.MaxFileBytes)
violations.Add($"Attachment '{a.FileName}' is {size:N0} bytes (max {limits.MaxFileBytes:N0})");
string ext = Path.GetExtension(Path.GetFileName(a.FileName ?? "")).TrimStart('.');
if (ext.Length > 0 && ForbiddenExtensions.Contains(ext))
violations.Add($"Attachment '{a.FileName}' has a forbidden extension (.{ext.ToLowerInvariant()})");
}
if (total > limits.MaxMessageBytes)
violations.Add($"Attachments total {total:N0} bytes (max {limits.MaxMessageBytes:N0} per message)");
return violations;
}
}
+125 -134
View File
@@ -1,6 +1,4 @@
using System.Diagnostics; using System.Diagnostics;
using System.Net.Http.Headers;
using System.Text;
using Fuchs.intranet; using Fuchs.intranet;
using Fuchs.Observability; using Fuchs.Observability;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
@@ -8,13 +6,17 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Newtonsoft.Json; using Newtonsoft.Json;
using OCORE.SQL; using OCORE.SQL;
using OCORE.web.pwmailer;
using static OCORE.SQL.sql; using static OCORE.SQL.sql;
namespace Fuchs.Services; namespace Fuchs.Services;
/// <summary> /// <summary>
/// Outbound communication service backed by the ProcessWeb Mailer API /// Outbound communication service: the Fuchs-specific parts of sending (recipient override safety
/// (POST https://api.processweb.de/mailer/push_com). /// net, signature, attachment limits, <c>fds__emaillog</c> audit, telemetry) on top of the OCORE_web
/// <see cref="IPWMailerClientService"/>, which owns the ProcessWeb Mailer API transport (request
/// shapes, auth, error headers, retries). Contract: https://processweb.de/api/mailer/fc — see
/// Docs/Concepts/processweb-mailer-interface.md and ADRs 0028/0029.
/// When <c>ProcessWebComSettings.Enabled</c> is <c>false</c> the service /// When <c>ProcessWebComSettings.Enabled</c> is <c>false</c> the service
/// only logs the intended communication without calling the API. /// only logs the intended communication without calling the API.
/// </summary> /// </summary>
@@ -24,7 +26,7 @@ public class ProcessWebComService : IComService
private readonly Fuchs_intranet _intranet; private readonly Fuchs_intranet _intranet;
private readonly ProcessWebComSettings _settings; private readonly ProcessWebComSettings _settings;
private readonly FuchsEmailSettings _emailSettings; private readonly FuchsEmailSettings _emailSettings;
private readonly IHttpClientFactory _httpClientFactory; private readonly IPWMailerClientService _mailer;
private const string SignatureIntro = private const string SignatureIntro =
"<p>&nbsp;</p><p style=\"margin:24px 0 16px 0;line-height:140%;\">" + "<p>&nbsp;</p><p style=\"margin:24px 0 16px 0;line-height:140%;\">" +
@@ -36,13 +38,13 @@ public class ProcessWebComService : IComService
Fuchs_intranet intranet, Fuchs_intranet intranet,
IOptions<ProcessWebComSettings> settings, IOptions<ProcessWebComSettings> settings,
IOptions<FuchsEmailSettings> emailSettings, IOptions<FuchsEmailSettings> emailSettings,
IHttpClientFactory httpClientFactory) IPWMailerClientService mailer)
{ {
_logger = logger; _logger = logger;
_intranet = intranet; _intranet = intranet;
_settings = settings.Value; _settings = settings.Value;
_emailSettings = emailSettings.Value; _emailSettings = emailSettings.Value;
_httpClientFactory = httpClientFactory; _mailer = mailer;
} }
public async Task<bool> SendEmailAsync(string reference, string subject, string html, public async Task<bool> SendEmailAsync(string reference, string subject, string html,
@@ -74,8 +76,8 @@ public class ProcessWebComService : IComService
FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "invalid-email")); FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "invalid-email"));
act?.SetStatus(ActivityStatusCode.Error, "invalid-email"); act?.SetStatus(ActivityStatusCode.Error, "invalid-email");
// Every attempt is audit-logged, including this early rejection (previously it returned unlogged). // Every attempt is audit-logged, including this early rejection (previously it returned unlogged).
await WriteAuditLogAsync(reference, "", "", default, false, await WriteAuditLogAsync(reference, "", BuildAuditConfig(subject, email, name, cc, attachments?.Keys),
[$"Invalid recipient email address: '{email}'"]); default, false, [$"Invalid recipient email address: '{email}'"]);
return false; return false;
} }
@@ -86,54 +88,91 @@ public class ProcessWebComService : IComService
_logger.LogInformation( _logger.LogInformation(
"[ComService DISABLED] Would send email ref={Reference} subject='{Subject}' to={Email}", "[ComService DISABLED] Would send email ref={Reference} subject='{Subject}' to={Email}",
reference, subject, email); reference, subject, email);
await WriteAuditLogAsync(reference, "", "", default, false, ["Service disabled – email not sent"]); await WriteAuditLogAsync(reference, "", BuildAuditConfig(subject, email, name, cc, attachments?.Keys),
default, false, ["Service disabled – email not sent"]);
FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "disabled")); FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "disabled"));
return false; return false;
} }
bool success = false; bool success = false;
string messageId = ""; string messageId = "";
string failureReason = "api-error";
var errors = new List<string>(); var errors = new List<string>();
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
try try
{ {
// Attachments are transmitted inline as base64 in the same push_com POST. var message = new PWMailerMessage
// Each entry: { filename, mimeType, contentBase64 }.
var attachmentPayload = (attachments ?? new Dictionary<string, byte[]>())
.Where(kv => kv.Value is { Length: > 0 })
.Select(kv => new
{
filename = kv.Key,
mimeType = GuessMimeType(kv.Key),
contentBase64 = Convert.ToBase64String(kv.Value)
})
.ToArray();
var communication = new
{ {
to = email, To = email, Cc = cc, Subject = subject, Body = body, Reference = reference, ComType = "e"
cc,
subject,
body,
attachments = attachmentPayload
}; };
foreach (var (fileName, bytes) in attachments ?? new Dictionary<string, byte[]>())
message.Attachments.Add(new PWMailerAttachment(fileName, bytes));
_logger.LogDebug("SendEmailAsync ref={Reference} to={Email} cc={Cc} attachments={Count}", _logger.LogDebug("SendEmailAsync ref={Reference} to={Email} cc={Cc} attachments={Count}",
reference, email, cc ?? "", attachmentPayload.Length); reference, email, cc ?? "", message.Attachments.Count);
var (ok, status, responseBody) = await PostToApiAsync(reference, "e", communication); // PushAsync never throws for HTTP/transport/validation problems; the result says what happened.
if (ok) // Pre-validation (contract rules + Fuchs attachment limits, see FuchsMailerClient) runs inside
// it and is reported as a refused call: StatusCode 0 and no attempt made.
var result = await _mailer.PushAsync(message);
if (result.Success)
{ {
success = true; var receipt = result.Value!;
messageId = Guid.NewGuid().ToString("N"); if (message.Attachments.Count > 0 && receipt.AttachmentsProcessed != message.Attachments.Count)
{
// A 200 alone does not prove the files made it: the contract reports how many
// attachments were received and actually processed. Anything but an exact match
// is a failed send from the caller's point of view.
failureReason = "attachments-mismatch";
errors.Add($"Mailer accepted the message but not all {message.Attachments.Count} attachment(s): " +
$"attachmentsReceived={receipt.AttachmentsReceived}, attachmentsProcessed={receipt.AttachmentsProcessed}");
_logger.LogError("SendEmailAsync: attachment count mismatch for {Reference}: sent={Sent} received={Received} processed={Processed} comStackID={ComStackId}",
reference, message.Attachments.Count, receipt.AttachmentsReceived, receipt.AttachmentsProcessed, receipt.ComStackID);
}
else
{
success = true;
}
// comStackID is what getComStatus/getCom accept, so it is the audit key either way.
messageId = receipt.ComStackID;
}
else if (result.IsConflict)
{
// Idempotency guard: an identical message (same server/reference/content) is still
// pending at the Mailer and WILL be delivered; this one was not queued a second time.
// From the caller's point of view the mail is on its way — a resend clicked twice
// must not surface as a send error — so this is a success with a note in the audit log.
success = true;
errors.Add($"Duplicate of a still-pending entry (409) — the earlier entry will be sent, nothing new was queued: {result.Error}");
_logger.LogInformation("SendEmailAsync: duplicate of pending entry for {Reference}, earlier entry will be sent: {Error}",
reference, result.Error);
}
else if (result.Attempts == 0 && result.StatusCode == 0 && result.Exception is ArgumentException)
{
// Refused by message/attachment pre-validation (PWMailerClientService.ValidateMessage,
// incl. FuchsMailerClient's limits): the client wraps the rule messages in an
// ArgumentException. A caller problem — one audit line per rule.
failureReason = "validation";
errors.AddRange(result.Error.Split("; ", StringSplitOptions.RemoveEmptyEntries));
_logger.LogWarning("SendEmailAsync: refused by pre-validation for {Reference}: {Error}", reference, result.Error);
}
else if (result.Attempts == 0 && result.StatusCode == 0)
{
// Refused before sending for an operational reason: unusable PWMailer options or a
// token that could not be resolved (Key Vault outage, missing secret). Nothing the
// caller did — an Error, and its own metric reason, so it is never mistaken for a
// bad attachment.
failureReason = "client-config";
errors.Add($"Mailer client not usable: {result.Error}");
_logger.LogError("SendEmailAsync: mailer client not usable for {Reference}: {Error}", reference, result.Error);
} }
else else
{ {
// Persist the mailer's raw response so an unsuccessful send is diagnosable from the log. // Persist the mailer's error so an unsuccessful send is diagnosable from the log.
errors.Add($"API error {(int)status} ({status}): {responseBody}"); errors.Add($"API error {result.StatusCode}: {result.Error}");
_logger.LogWarning("SendEmailAsync API error for {Reference}: {Status} {Body}", _logger.LogWarning("SendEmailAsync API error for {Reference}: {Status} {Error}",
reference, (int)status, responseBody); reference, result.StatusCode, result.Error);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -149,11 +188,12 @@ public class ProcessWebComService : IComService
FuchsTelemetry.EmailSendDuration.Record(sw.Elapsed.TotalMilliseconds, FuchsTelemetry.EmailSendDuration.Record(sw.Elapsed.TotalMilliseconds,
new KeyValuePair<string, object?>("success", success)); new KeyValuePair<string, object?>("success", success));
if (success) FuchsTelemetry.EmailsSent.Add(1); if (success) FuchsTelemetry.EmailsSent.Add(1);
else FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "api-error")); else FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", failureReason));
_logger.LogInformation("SendEmailAsync ref={Reference} success={Success} in {Ms} ms", _logger.LogInformation("SendEmailAsync ref={Reference} success={Success} in {Ms} ms",
reference, success, sw.ElapsedMilliseconds); reference, success, sw.ElapsedMilliseconds);
await WriteAuditLogAsync(reference, messageId, "", success ? DateTime.UtcNow : default, success, errors); await WriteAuditLogAsync(reference, messageId, BuildAuditConfig(subject, email, name, cc, attachments?.Keys),
success ? DateTime.UtcNow : default, success, errors);
return success; return success;
} }
@@ -175,21 +215,18 @@ public class ProcessWebComService : IComService
try try
{ {
var communication = new var result = await _mailer.PushAsync(new PWMailerMessage
{ {
to = mobile, To = mobile, Body = message, Reference = mobile, ComType = "s"
body = message });
}; if (result.Success)
var (ok, status, responseBody) = await PostToApiAsync(mobile, "s", communication);
if (ok)
{ {
FuchsTelemetry.SmsSent.Add(1); FuchsTelemetry.SmsSent.Add(1);
_logger.LogInformation("SendSmsAsync sent to {Mobile}", mobile); _logger.LogInformation("SendSmsAsync sent to {Mobile}", mobile);
return true; return true;
} }
_logger.LogWarning("SendSmsAsync API error for {Mobile}: {Status} {Body}", mobile, (int)status, responseBody); _logger.LogWarning("SendSmsAsync API error for {Mobile}: {Status} {Error}", mobile, result.StatusCode, result.Error);
return false; return false;
} }
catch (Exception ex) catch (Exception ex)
@@ -204,85 +241,33 @@ public class ProcessWebComService : IComService
if (string.IsNullOrWhiteSpace(_settings.AccountId) || string.IsNullOrWhiteSpace(_settings.Token)) if (string.IsNullOrWhiteSpace(_settings.AccountId) || string.IsNullOrWhiteSpace(_settings.Token))
return new MailerCredentialsCheckResult { Ok = false, Detail = "Konto-ID oder Token nicht konfiguriert." }; return new MailerCredentialsCheckResult { Ok = false, Detail = "Konto-ID oder Token nicht konfiguriert." };
var client = _httpClientFactory.CreateClient("ProcessWebMailer");
string credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_settings.AccountId}:{_settings.Token}"));
// Passing serverid makes the Mailer API also confirm that THIS server is one of the // Passing serverid makes the Mailer API also confirm that THIS server is one of the
// account's usable servers, not just that the account/token pair is valid. // account's usable servers, not just that the account/token pair is valid.
string url = $"{_settings.BaseUrl}/mailer/fc?fn=verify"; string? serverId = string.IsNullOrWhiteSpace(_settings.ServerId) ? null : _settings.ServerId;
if (!string.IsNullOrWhiteSpace(_settings.ServerId)) var result = await _mailer.VerifyAsync(serverId, cancellationToken);
url += $"&serverid={Uri.EscapeDataString(_settings.ServerId)}";
using var request = new HttpRequestMessage(HttpMethod.Get, url); if (!result.Success)
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
try
{ {
using var response = await client.SendAsync(request, cancellationToken); if (result.IsTransportError) _logger.LogWarning("VerifyCredentialsAsync failed: {Error}", result.Error);
string body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
return new MailerCredentialsCheckResult
{
Ok = false,
Detail = $"API-Fehler {(int)response.StatusCode} ({response.StatusCode}): {body}",
};
var payload = JsonConvert.DeserializeObject<MailerVerifyResponse>(body);
bool ok = payload?.Ok == true;
string? detail = ok ? null
: (!string.IsNullOrWhiteSpace(payload?.ServerId)
? $"Server-ID '{payload!.ServerId}' gehört nicht zu diesem Mailer-Konto."
: "Mailer API meldete ungültige Zugangsdaten.");
return new MailerCredentialsCheckResult return new MailerCredentialsCheckResult
{ {
Ok = ok, Ok = false,
Account = payload?.Account, Detail = result.IsTransportError ? result.Error : $"API-Fehler {result.StatusCode}: {result.Error}",
ServerId = payload?.ServerId,
Detail = detail,
}; };
} }
catch (Exception ex)
var payload = result.Value!;
string? detail = payload.Ok ? null
: (!string.IsNullOrWhiteSpace(payload.ServerId)
? $"Server-ID '{payload.ServerId}' gehört nicht zu diesem Mailer-Konto."
: "Mailer API meldete ungültige Zugangsdaten.");
return new MailerCredentialsCheckResult
{ {
_logger.LogWarning(ex, "VerifyCredentialsAsync failed"); Ok = payload.Ok,
return new MailerCredentialsCheckResult { Ok = false, Detail = ex.Message }; Account = string.IsNullOrEmpty(payload.Account) ? null : payload.Account,
} ServerId = payload.ServerId,
} Detail = detail,
private sealed class MailerVerifyResponse
{
public bool Ok { get; set; }
public string? Account { get; set; }
public string? ServerId { get; set; }
}
// ── Private helpers ────────────────────────────────────────────────────────
private async Task<(bool ok, System.Net.HttpStatusCode status, string body)> PostToApiAsync(string reference, string comtype, object communication)
{
var client = _httpClientFactory.CreateClient("ProcessWebMailer");
// The Mailer API expects a fixed envelope: { serverid, comtype, communication, reference }.
// "communication" carries the actual channel-specific payload (to/subject/body/attachments).
var payload = new
{
serverid = _settings.ServerId,
comtype,
communication,
reference
}; };
var json = JsonConvert.SerializeObject(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
string credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_settings.AccountId}:{_settings.Token}"));
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
var response = await client.PostAsync($"{_settings.BaseUrl}/mailer/push_com", content);
string responseBody = await response.Content.ReadAsStringAsync();
return (response.IsSuccessStatusCode, response.StatusCode, responseBody);
} }
/// <summary> /// <summary>
@@ -308,6 +293,30 @@ public class ProcessWebComService : IComService
} }
} }
/// <summary>
/// Serialises the audit <c>config</c> stored in <c>fds__emaillog.config</c>: who the mail went
/// to, its subject and the attachment <em>names</em> — never the body or attachment bytes.
/// The shape (<c>subject</c>/<c>from</c>/<c>to[]</c>/<c>cc[]</c>/<c>bcc[]</c>/<c>attachments[]</c>,
/// addresses as <c>"Name" &lt;email&gt;</c>) is the one the legacy mailer wrote and that
/// <c>[dbo].[fds_wgd_lastEmails]</c> reads back with <c>OPENJSON</c>; an empty string here
/// made that dashboard widget fail for every row the new service logged.
/// Pure so the contract can be unit-tested without a database.
/// </summary>
internal static string BuildAuditConfig(string subject, string email, string? name, string? cc,
IEnumerable<string>? attachmentNames) =>
JsonConvert.SerializeObject(new
{
subject = subject ?? "",
from = "",
to = new[] { FormatAddress(email, name) },
cc = string.IsNullOrWhiteSpace(cc) ? Array.Empty<string>() : new[] { cc.Trim() },
bcc = Array.Empty<string>(),
attachments = (attachmentNames ?? []).ToArray()
});
private static string FormatAddress(string email, string? name) =>
string.IsNullOrWhiteSpace(name) ? (email ?? "") : $"\"{name.Trim()}\" <{email}>";
/// <summary> /// <summary>
/// Builds the parameter list for <c>[dbo].[fds__logEmail]</c>. <c>fds__emaillog</c> declares /// Builds the parameter list for <c>[dbo].[fds__logEmail]</c>. <c>fds__emaillog</c> declares
/// <c>config</c> and <c>DateSent</c> as NOT NULL, so neither may be sent as NULL: /// <c>config</c> and <c>DateSent</c> as NOT NULL, so neither may be sent as NULL:
@@ -349,24 +358,6 @@ public class ProcessWebComService : IComService
return ""; return "";
} }
private static string GuessMimeType(string filename) =>
Path.GetExtension(filename).ToLowerInvariant() switch
{
".pdf" => "application/pdf",
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".txt" => "text/plain",
".csv" => "text/csv",
".xml" => "application/xml",
".zip" => "application/zip",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls" => "application/vnd.ms-excel",
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
_ => "application/octet-stream"
};
private static bool IsValidEmail(string email) private static bool IsValidEmail(string email)
{ {
try try
+24 -3
View File
@@ -1,12 +1,13 @@
namespace Fuchs.Services; namespace Fuchs.Services;
/// <summary> /// <summary>
/// Settings for the ProcessWeb Mailer API, bound from appsettings.json → "Fuchs:Mailer". /// Settings for the ProcessWeb Mailer API, bound from appsettings.json → "Fuchs:Mailer".
/// Contract: https://processweb.de/api/mailer/fc (see Docs/Concepts/processweb-mailer-interface.md).
/// </summary> /// </summary>
public class ProcessWebComSettings public class ProcessWebComSettings
{ {
/// <summary>Base URL of the ProcessWeb API (e.g. "https://api.processweb.de").</summary> /// <summary>Base URL of the ProcessWeb host (e.g. "https://processweb.de"); endpoints live under <c>/api/mailer/…</c>.</summary>
public string BaseUrl { get; set; } = "https://api.processweb.de"; public string BaseUrl { get; set; } = "https://processweb.de";
/// <summary>Account ID used for HTTP Basic authentication.</summary> /// <summary>Account ID used for HTTP Basic authentication.</summary>
public string AccountId { get; set; } = ""; public string AccountId { get; set; } = "";
@@ -23,4 +24,24 @@ public class ProcessWebComSettings
/// Set to <c>true</c> to enable live sending. /// Set to <c>true</c> to enable live sending.
/// </summary> /// </summary>
public bool Enabled { get; set; } = false; public bool Enabled { get; set; } = false;
/// <summary>
/// Client-side mirror of the Mailer API's attachment limits, checked before a push_com call
/// so a violation is reported with a precise reason instead of an opaque 400. Defaults are
/// the contract's documented defaults; the server stays authoritative.
/// </summary>
public MailerAttachmentLimits Attachments { get; set; } = new();
}
/// <summary>See <see cref="ProcessWebComSettings.Attachments"/>.</summary>
public sealed class MailerAttachmentLimits
{
/// <summary>Maximum size of a single attachment in bytes (contract default: 10 MB).</summary>
public long MaxFileBytes { get; set; } = 10L * 1024 * 1024;
/// <summary>Maximum total attachment size per message in bytes (contract default: 20 MB).</summary>
public long MaxMessageBytes { get; set; } = 20L * 1024 * 1024;
/// <summary>Maximum number of attachments per message (contract default: 10).</summary>
public int MaxCount { get; set; } = 10;
} }
+80 -49
View File
@@ -59,27 +59,30 @@ public sealed class ReminderDraftEditService : IReminderDraftService
var session = _cache.Get(token); var session = _cache.Get(token);
if (session == null) return null; if (session == null) return null;
string oldValue = "", newValue = ""; lock (session.SyncRoot) // concurrent patch/state/preview requests for one token must not interleave
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
if (!mutated)
{ {
_logger.LogDebug("Reminder draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref); string oldValue = "", newValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
if (!mutated)
{
_logger.LogDebug("Reminder draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
return session;
}
Refresh(session);
session.Version++;
session.History.Add(new ChangeHistoryEntry
{
UserAccountId = session.UserAccountId,
Target = delta.Target,
Ref = delta.Ref,
OldValue = oldValue,
NewValue = newValue,
Version = session.Version
});
_cache.Set(session);
return session; return session;
} }
Refresh(session);
session.Version++;
session.History.Add(new ChangeHistoryEntry
{
UserAccountId = session.UserAccountId,
Target = delta.Target,
Ref = delta.Ref,
OldValue = oldValue,
NewValue = newValue,
Version = session.Version
});
_cache.Set(session);
return session;
} }
/// <summary> /// <summary>
@@ -144,24 +147,27 @@ public sealed class ReminderDraftEditService : IReminderDraftService
public object BuildState(ReminderDraftSession session) public object BuildState(ReminderDraftSession session)
{ {
session.Touch(); session.Touch();
return new lock (session.SyncRoot) // serialised by the caller later — hand out snapshots, not live references
{ {
token = session.Token, return new
version = session.Version,
remid = session.RemId,
isDraft = session.IsDraft,
@new = session.New,
rem = session.Rem,
sums = new
{ {
amount_total = session.Sums.AmountTotal, token = session.Token,
amount_payed = session.Sums.AmountPayed, version = session.Version,
amount_open = session.Sums.AmountOpen remid = session.RemId,
}, isDraft = session.IsDraft,
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }), @new = session.New.DeepClone(),
historyCount = session.History.Count, rem = session.Rem.DeepClone(),
notes = BuildNotes(session) sums = new
}; {
amount_total = session.Sums.AmountTotal,
amount_payed = session.Sums.AmountPayed,
amount_open = session.Sums.AmountOpen
},
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }).ToList(),
historyCount = session.History.Count,
notes = BuildNotes(session)
};
}
} }
/// <summary> /// <summary>
@@ -177,34 +183,58 @@ public sealed class ReminderDraftEditService : IReminderDraftService
return new { intro, closing }; return new { intro, closing };
} }
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) => public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token)
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>(); {
var session = _cache.Get(token);
if (session == null) return Array.Empty<ChangeHistoryEntry>();
lock (session.SyncRoot) return session.History.ToList();
}
// ── Flush / preview ──────────────────────────────────────────────────────── // ── Flush / preview ────────────────────────────────────────────────────────
public async Task<FdsReminderData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec) public async Task<DraftFlushResult<FdsReminderData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
{ {
var session = _cache.Get(token); var session = _cache.Get(token);
if (session == null) return null; if (session == null) return null;
var fds = BuildReminderData(session); // One flush at a time per session (see InvoiceDraftEditService.FlushToDbAsync): two overlapping
bool change = !string.IsNullOrEmpty(session.RemId); // saves of a new draft would otherwise both see an empty RemId and both call fds__createReminder.
var reg = await _reminders.RegisterReminderAsync(fds, change, session.RemId, userAccountId, dbSec); await session.FlushGate.WaitAsync();
if (!string.IsNullOrEmpty(reg.Id)) try
{ {
session.RemId = reg.Id; FdsReminderData fds;
_cache.Set(session); string remId;
_logger.LogInformation("Reminder draft {Token} flushed to DB reminder {RemId} (change={Change}, user={User})", lock (session.SyncRoot)
token, reg.Id, change, userAccountId); {
fds = BuildReminderData(session); // deep-cloned snapshot — the await below runs outside the lock
remId = session.RemId;
}
bool change = !string.IsNullOrEmpty(remId);
var reg = await _reminders.RegisterReminderAsync(fds, change, remId, userAccountId, dbSec);
if (!string.IsNullOrEmpty(reg.Id))
{
lock (session.SyncRoot) session.RemId = reg.Id;
_cache.Set(session);
_logger.LogInformation("Reminder draft {Token} flushed to DB reminder {RemId} (change={Change}, user={User})",
token, reg.Id, change, userAccountId);
}
return new DraftFlushResult<FdsReminderData>(reg, change);
}
finally
{
session.FlushGate.Release();
} }
return reg;
} }
public Document? RenderPreview(string token) public Document? RenderPreview(string token)
{ {
var session = _cache.Get(token); var session = _cache.Get(token);
if (session == null) return null; if (session == null) return null;
var fds = BuildReminderData(session); FdsReminderData fds;
fds.ReminderRegistration = SynthesizeRegistration(session); lock (session.SyncRoot)
{
fds = BuildReminderData(session);
fds.ReminderRegistration = SynthesizeRegistration(session);
}
fds.IsDraft = true; fds.IsDraft = true;
return _reminders.GenerateReminderPdf(fds, draft: true); return _reminders.GenerateReminderPdf(fds, draft: true);
} }
@@ -271,7 +301,8 @@ public sealed class ReminderDraftEditService : IReminderDraftService
["invoices"] = invoices, ["invoices"] = invoices,
["CustomValues"] = Str(session.New["CustomValues"]), ["CustomValues"] = Str(session.New["CustomValues"]),
["IsFinal"] = false, ["IsFinal"] = false,
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) // UTC like the stored column — InvoiceDates.IssueDate converts it to the German day
["DateCreated"] = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
}; };
return new GenericObjectDictionary(d); return new GenericObjectDictionary(d);
} }
+48 -12
View File
@@ -63,6 +63,25 @@ public class ReminderService : IReminderService
return rem; return rem;
} }
/// <summary>
/// The save batch for <see cref="RegisterReminderAsync"/>: <c>fds__createReminder</c> for a new
/// draft, <c>fds__setReminder</c> (update in place, same result row) for a re-saved one. Either
/// proc leaves <c>@Id</c> NULL when it did not save — the set proc refuses a finalised reminder
/// or one with a stored file — so the guard turns that into an error instead of an empty result
/// row the caller would read as "saved".
/// </summary>
internal static string BuildRegisterReminderSql(bool change)
{
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
sqlParts.Add(change
? "EXECUTE [dbo].[fds__setReminder] @RemId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;"
: "EXECUTE [dbo].[fds__createReminder] @InvId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;");
sqlParts.Add(change
? "IF @Id IS NULL THROW 50001, N'fds__setReminder did not return a reminder id - reminder draft not saved (finalised, has a file, or unknown).', 1;"
: "IF @Id IS NULL THROW 50001, N'fds__createReminder did not return a reminder id - reminder not saved.', 1;");
return string.Join("\n", sqlParts);
}
public async Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId, public async Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId,
string userAccountId, DatabaseSecurity dbSec) string userAccountId, DatabaseSecurity dbSec)
{ {
@@ -82,20 +101,17 @@ public class ReminderService : IReminderService
SQL_NVarChar("text", reminder.NewValues?.nz("text") ?? "", dbNull_IfEmpty: true) SQL_NVarChar("text", reminder.NewValues?.nz("text") ?? "", dbNull_IfEmpty: true)
}; };
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" }; change = change && !string.IsNullOrEmpty(remId);
if (!change || string.IsNullOrEmpty(remId)) if (change) pl.Add(SQL_VarChar("RemId", remId));
sqlParts.Add("EXECUTE [dbo].[fds__createReminder] @InvId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;");
else
pl.Add(SQL_VarChar("RemId", remId));
var remdset = await getSQLDataSet_async(string.Join("\n", sqlParts), var remdset = await getSQLDataSet_async(BuildRegisterReminderSql(change),
Conn, pl, tablenames: new[] { "rem" }, Conn, pl, tablenames: new[] { "rem" },
Security: dbSec, options: new FIS_SQLOptions()); Security: dbSec, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(remdset.Exception)) if (!string.IsNullOrEmpty(remdset.Exception))
_logger.LogError("RegisterReminderAsync sql exception: {Ex}", remdset.Exception); _logger.LogError("RegisterReminderAsync sql exception: {Ex}", remdset.Exception);
reminder.ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary()); reminder.ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary());
_logger.LogInformation("RegisterReminderAsync registered id={Id}", reminder.Id); _logger.LogInformation("RegisterReminderAsync registered id={Id} (change={Change})", reminder.Id, change);
return reminder; return reminder;
} }
@@ -111,7 +127,8 @@ public class ReminderService : IReminderService
Address = reminder.InvoiceAddress, Address = reminder.InvoiceAddress,
AdminUser = reminder.UserNameFinalized, AdminUser = reminder.UserNameFinalized,
AdminUserEmail = reminder.UserEmailFinalized, AdminUserEmail = reminder.UserEmailFinalized,
AdminDatumValue = reminder.DateCreated ?? DateTime.Now // Reminder date = DateFinalized, draft: DateCreated — same rule as the invoice date (ADR 0035)
AdminDatumValue = InvoiceDates.IssueDate(reminder.ReminderRegistration).ToDateTime(TimeOnly.MinValue)
}; };
if (!string.IsNullOrEmpty(reminder.RawCustomValues)) if (!string.IsNullOrEmpty(reminder.RawCustomValues))
{ {
@@ -139,8 +156,18 @@ public class ReminderService : IReminderService
{ {
byte[] ba; byte[] ba;
try { ba = await RenderReminderPdfBytesAsync(reminder, draft); } try { ba = await RenderReminderPdfBytesAsync(reminder, draft); }
catch (Exception ex) { _logger.LogError(ex, "StoreReminderDocumentFileAsync render failed for {Id}", reminder.Id); ba = Array.Empty<byte>(); } catch (Exception ex)
if (ba.Length == 0) return Array.Empty<byte>(); {
_logger.LogError(ex, "StoreReminderDocumentFileAsync render failed for {Id}", reminder.Id);
return Array.Empty<byte>();
}
if (ba.Length == 0)
{
_logger.LogError(
"StoreReminderDocumentFileAsync: render for {Id} completed without an exception but produced 0 bytes, draft={Draft}, user={User}",
reminder.Id, draft, userAccountId);
return Array.Empty<byte>();
}
var pl = new List<SqlParameter> var pl = new List<SqlParameter>
{ {
@@ -148,10 +175,19 @@ public class ReminderService : IReminderService
SQL_VarChar("@authuser", userAccountId), SQL_VarChar("@authuser", userAccountId),
new("@file", SqlDbType.VarBinary) { Value = ba } new("@file", SqlDbType.VarBinary) { Value = ba }
}; };
bool r = await setSQLValue_async( // OUTPUT-clause row count, not rows-affected (SET NOCOUNT ON) — see the matching note in
// InvoiceService.StoreInvoiceDocumentFileAsync / ADR 0025.
var stored = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__setReminderFile] @Id, @file;", "EXECUTE [dbo].[fds__setReminderFile] @Id, @file;",
Conn, pl, Security: dbSec, options: new FIS_SQLOptions()); Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
if (!r) return Array.Empty<byte>(); if (!string.IsNullOrEmpty(stored.Exception)) return Array.Empty<byte>();
if (stored.Count == 0)
{
_logger.LogError(
"StoreReminderDocumentFileAsync: fds__setReminderFile updated 0 rows for {Id} (file already stored, or reminder missing), user={User}",
reminder.Id, userAccountId);
return Array.Empty<byte>();
}
string fileName = reminder.ReminderRegistration?.getString("DocumentName") string fileName = reminder.ReminderRegistration?.getString("DocumentName")
.ne($"Zahlungserinnerung_{reminder.Id}.pdf") ?? $"Zahlungserinnerung_{reminder.Id}.pdf"; .ne($"Zahlungserinnerung_{reminder.Id}.pdf") ?? $"Zahlungserinnerung_{reminder.Id}.pdf";
+6
View File
@@ -129,4 +129,10 @@ public sealed class EmailTestResult
/// (see <see cref="FuchsEmailSettings.OverrideRecipient"/>); otherwise <c>null</c>. /// (see <see cref="FuchsEmailSettings.OverrideRecipient"/>); otherwise <c>null</c>.
/// </summary> /// </summary>
public string? OverrideRecipient { get; init; } public string? OverrideRecipient { get; init; }
/// <summary>
/// File name of the PDF that was attached to the test mail, or <c>null</c> when the mail was
/// sent without an attachment (or never reached the Mailer).
/// </summary>
public string? AttachmentName { get; init; }
} }
+64 -6
View File
@@ -1,6 +1,7 @@
using System.Diagnostics; using System.Diagnostics;
using Azure; using Azure;
using Azure.Security.KeyVault.Secrets; using Azure.Security.KeyVault.Secrets;
using Fuchs.intranet;
using Fuchs.Observability; using Fuchs.Observability;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
@@ -28,6 +29,7 @@ public sealed class SystemStatusService : ISystemStatusService
private readonly IBlobStorageService _blob; private readonly IBlobStorageService _blob;
private readonly IMfrClientFactory _mfrFactory; private readonly IMfrClientFactory _mfrFactory;
private readonly IComService _comService; private readonly IComService _comService;
private readonly IPdfService _pdf;
private readonly ProcessWebComSettings _mailer; private readonly ProcessWebComSettings _mailer;
private readonly FuchsEmailSettings _email; private readonly FuchsEmailSettings _email;
private readonly AzureBlobStorageSettings _blobSettings; private readonly AzureBlobStorageSettings _blobSettings;
@@ -41,6 +43,7 @@ public sealed class SystemStatusService : ISystemStatusService
IBlobStorageService blob, IBlobStorageService blob,
IMfrClientFactory mfrFactory, IMfrClientFactory mfrFactory,
IComService comService, IComService comService,
IPdfService pdf,
IOptions<ProcessWebComSettings> mailer, IOptions<ProcessWebComSettings> mailer,
IOptions<FuchsEmailSettings> email, IOptions<FuchsEmailSettings> email,
IOptions<AzureBlobStorageSettings> blobSettings, IOptions<AzureBlobStorageSettings> blobSettings,
@@ -53,6 +56,7 @@ public sealed class SystemStatusService : ISystemStatusService
_blob = blob; _blob = blob;
_mfrFactory = mfrFactory; _mfrFactory = mfrFactory;
_comService = comService; _comService = comService;
_pdf = pdf;
_mailer = mailer.Value; _mailer = mailer.Value;
_email = email.Value; _email = email.Value;
_blobSettings = blobSettings.Value; _blobSettings = blobSettings.Value;
@@ -409,8 +413,11 @@ public sealed class SystemStatusService : ISystemStatusService
} }
} }
/// <summary>File name of the PDF attached to a test email when <c>attachPdf</c> is requested.</summary>
internal const string TestPdfAttachmentName = "Fuchs-Intranet-Testmail.pdf";
public async Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body, public async Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body,
CancellationToken cancellationToken = default) bool attachPdf = true, CancellationToken cancellationToken = default)
{ {
to = (to ?? "").Trim(); to = (to ?? "").Trim();
subject = (subject ?? "").Trim(); subject = (subject ?? "").Trim();
@@ -422,14 +429,43 @@ public sealed class SystemStatusService : ISystemStatusService
// Body arrives as plain text from the Admin form; wrap the (HTML-encoded) lines in <p>. // Body arrives as plain text from the Admin form; wrap the (HTML-encoded) lines in <p>.
string html = "<p>" + System.Net.WebUtility.HtmlEncode(body).Replace("\r\n", "\n").Replace("\n", "<br/>") + "</p>"; string html = "<p>" + System.Net.WebUtility.HtmlEncode(body).Replace("\r\n", "\n").Replace("\n", "<br/>") + "</p>";
bool sent = await _comService.SendEmailAsync("admin_test", subject, html, to, "Admin Test", attachments: null); // Optional PDF attachment: exercises the Mailer's multipart/attachment path end-to-end
// (ADR 0028 — the send only counts as successful when the Mailer confirms the attachment).
// The PDF is the same letter layout the invoices/reminders use, rendered in memory from
// the test mail's own subject/body, so no DB or stored document is involved.
Dictionary<string, byte[]>? attachments = null;
if (attachPdf)
{
try
{
attachments = new Dictionary<string, byte[]>
{
[TestPdfAttachmentName] = await RenderTestPdfAsync(to, subject, body),
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Admin test email: rendering the PDF attachment failed");
return new EmailTestResult
{
Sent = false,
Message = $"PDF-Anhang konnte nicht erzeugt werden: {ex.Message}",
RequestedRecipient = to,
};
}
}
bool sent = await _comService.SendEmailAsync("admin_test", subject, html, to, "Admin Test", attachments);
string? overrideTo = string.IsNullOrWhiteSpace(_email.OverrideRecipient) ? null : _email.OverrideRecipient; string? overrideTo = string.IsNullOrWhiteSpace(_email.OverrideRecipient) ? null : _email.OverrideRecipient;
string attachmentNote = attachments is null ? "" : $" (mit PDF-Anhang {TestPdfAttachmentName})";
string message = sent string message = sent
? (overrideTo is null ? (overrideTo is null
? "Test-E-Mail wurde an den Mailer übergeben." ? $"Test-E-Mail wurde an den Mailer übergeben{attachmentNote}."
: $"Test-E-Mail wurde an den Mailer übergeben (durch OverrideRecipient umgeleitet an {overrideTo}).") : $"Test-E-Mail wurde an den Mailer übergeben{attachmentNote} (durch OverrideRecipient umgeleitet an {overrideTo}).")
: "Test-E-Mail wurde vom Mailer nicht akzeptiert (ggf. ist der Mailer deaktiviert)."; : (attachments is null
? "Test-E-Mail wurde vom Mailer nicht akzeptiert (ggf. ist der Mailer deaktiviert)."
: "Test-E-Mail wurde vom Mailer nicht akzeptiert oder der PDF-Anhang wurde nicht bestätigt (ggf. ist der Mailer deaktiviert).");
return new EmailTestResult return new EmailTestResult
{ {
@@ -437,6 +473,28 @@ public sealed class SystemStatusService : ISystemStatusService
Message = message, Message = message,
RequestedRecipient = to, RequestedRecipient = to,
OverrideRecipient = overrideTo, OverrideRecipient = overrideTo,
AttachmentName = attachments is null ? null : TestPdfAttachmentName,
}; };
} }
/// <summary>
/// Renders the test mail as a one-page letter (standard Fuchs letterhead) to PDF/A bytes.
/// Pure in-memory: text blocks are built from the request, nothing is loaded or persisted.
/// </summary>
private async Task<byte[]> RenderTestPdfAsync(string to, string subject, string body)
{
var tb = new FuchsPdf.FdsTextBlocks
{
Address = new[] { "Test-Empfänger", to },
AdminRef = "admin_test",
AdminDatumLabel = "Datum",
AdminDatumValue = DateTime.Now,
AdminProvLabel = "Umgebung",
ProvisionPeriod = _environment.EnvironmentName,
Subject = subject,
Body = body.Replace("\r\n", "\n"),
};
var doc = await _pdf.WriteLetterAsync(tb, draft: false);
return _pdf.DocToPdfBytes(doc);
}
} }
+1 -1
View File
@@ -28,7 +28,7 @@
"CheckPdfLicense": true "CheckPdfLicense": true
}, },
"Mailer": { "Mailer": {
"BaseUrl": "https://api.processweb.de", "BaseUrl": "https://processweb.de",
"AccountId": "82d87114-c8c3-4d33-95e5-4c781a9229ab", "AccountId": "82d87114-c8c3-4d33-95e5-4c781a9229ab",
"Token": "SHWRQGM4C3P6VAFFG6RA", "Token": "SHWRQGM4C3P6VAFFG6RA",
"ServerId": "87AAB12E-29E0-47AB-8DD1-5F5D49B4AC2E", "ServerId": "87AAB12E-29E0-47AB-8DD1-5F5D49B4AC2E",
+12 -2
View File
@@ -42,11 +42,16 @@
"fuchs_intranet_TOTP": "MANAGED_BY_KEYVAULT", "fuchs_intranet_TOTP": "MANAGED_BY_KEYVAULT",
"SMS_APIKey": "MANAGED_BY_KEYVAULT", "SMS_APIKey": "MANAGED_BY_KEYVAULT",
"Mailer": { "Mailer": {
"BaseUrl": "https://api.processweb.de", "BaseUrl": "https://processweb.de",
"AccountId": "7da3c1e6-4180-42d6-9a60-5a09d005cce6", "AccountId": "7da3c1e6-4180-42d6-9a60-5a09d005cce6",
"Token": "MANAGED_BY_KEYVAULT", "Token": "MANAGED_BY_KEYVAULT",
"ServerId": "1192f3bf-57e5-4fd2-bb5d-3b7b983a2d98", "ServerId": "1192f3bf-57e5-4fd2-bb5d-3b7b983a2d98",
"Enabled": true "Enabled": true,
"Attachments": {
"MaxFileBytes": 10485760,
"MaxMessageBytes": 20971520,
"MaxCount": 10
}
}, },
"Email": { "Email": {
"OverrideRecipient": "" "OverrideRecipient": ""
@@ -70,6 +75,11 @@
"Enabled": true, "Enabled": true,
"OtlpEndpoint": "" "OtlpEndpoint": ""
}, },
"Logging": {
"SqlSink": {
"Enabled": true
}
},
"ERechnung": { "ERechnung": {
"Enabled": false, "Enabled": false,
"Profile": "EN16931", "Profile": "EN16931",
+19
View File
@@ -173,6 +173,25 @@
], ],
"minify": { "enabled": true } "minify": { "enabled": true }
}, },
{
"context": "intranet:specialties",
"outputFileName": "wwwroot/web/fis.specialties.de.js",
"inputFiles": [
"js/intranet/modules/fis.specialties_txt_de.js",
"js/intranet/modules/fis.specialties.js"
],
"minify": { "enabled": true }
},
{
"context": "intranet:specialties",
"outputFileName": "wwwroot/web/fis.specialties.min.css",
"inputFiles": [
"css/intranet/oci_variables.scss",
"css/intranet/fis_variables.scss",
"js/intranet/modules/fis.specialties.scss"
],
"minify": { "enabled": true }
},
{ {
"context": "intranet:usr", "context": "intranet:usr",
"outputFileName": "wwwroot/web/fis.usr.de.js", "outputFileName": "wwwroot/web/fis.usr.de.js",
+64 -5
View File
@@ -173,9 +173,9 @@ public class FdsInvoiceData
{ {
SQL_VarChar("@InvoiceType", Admin?.nz("type") ?? ""), SQL_VarChar("@InvoiceType", Admin?.nz("type") ?? ""),
SQL_NVarChar("@InvoiceTitle", NewValues?.nz("title") ?? ""), SQL_NVarChar("@InvoiceTitle", NewValues?.nz("title") ?? ""),
SQL_Float("@InvoiceBalance", stringvalue: NewValues?.nz("total_gross") ?? "0"), AmountParam("@InvoiceBalance", NewValues is null ? 0m : NewValues.getItem("total_gross"), scale: 3),
SQL_Float("@InvoiceBalance_net", stringvalue: NewValues?.nz("total_net") ?? "0"), AmountParam("@InvoiceBalance_net", NewValues is null ? 0m : NewValues.getItem("total_net"), scale: 3),
SQL_Float("@InvoiceVAT_net1", stringvalue: vatNet), AmountParam("@InvoiceVAT_net1", vatNet, scale: 3),
SQL_VarChar("@InvoiceVAT_1", vatRate), SQL_VarChar("@InvoiceVAT_1", vatRate),
SQL_VarChar("@PaymentTerm", NewValues?.nz("paymentterm") ?? "", dbNull_IfEmpty: true), SQL_VarChar("@PaymentTerm", NewValues?.nz("paymentterm") ?? "", dbNull_IfEmpty: true),
SQL_BigInt("@CustomerId", Admin?.nz("customerid") ?? ""), SQL_BigInt("@CustomerId", Admin?.nz("customerid") ?? ""),
@@ -185,12 +185,63 @@ public class FdsInvoiceData
SQL_NVarChar("@ProvisionPeriod", RawProvisionPeriod, dbNull_IfEmpty: true), SQL_NVarChar("@ProvisionPeriod", RawProvisionPeriod, dbNull_IfEmpty: true),
SQL_NVarChar("@CustomValues", RawCustomValues, dbNull_IfEmpty: true), SQL_NVarChar("@CustomValues", RawCustomValues, dbNull_IfEmpty: true),
SQL_NVarChar("@SendToAddressJson", SendToAddressJsonForPersist(), dbNull_IfEmpty: true), SQL_NVarChar("@SendToAddressJson", SendToAddressJsonForPersist(), dbNull_IfEmpty: true),
SQL_Float("@InvoiceService_net", stringvalue: Sms?.nz("tscn") ?? "0"), AmountParam("@InvoiceService_net", Sms is null ? 0m : Sms.getItem("tscn"), scale: 2),
SQL_Float("@InvoiceService_VAT", stringvalue: Sms?.nz("tscvat") ?? "0"), AmountParam("@InvoiceService_VAT", Sms is null ? 0m : Sms.getItem("tscvat"), scale: 2),
SQL_VarChar("@InvoiceOptions", BuildInvoiceOptions(), dbNull_IfEmpty: true) SQL_VarChar("@InvoiceOptions", BuildInvoiceOptions(), dbNull_IfEmpty: true)
}; };
} }
/// <summary>Largest magnitude the <c>numeric(10,x)</c> amount parameters of the invoice procs accept.</summary>
internal const decimal MaxAmount = 9_999_999.99m;
/// <summary>
/// Invoice amounts go to SQL as exact <c>decimal</c> parameters rounded to cents — never through
/// a culture-dependent string → float round trip. That round trip once parsed negative amounts
/// with the server's de-DE culture ("-615.3758" → -6153758, with float noise beyond numeric(10,3)),
/// so Stornorechnungen could not be saved. An amount outside the procs' range is refused here
/// (<see cref="InvoiceAmountOutOfRangeException"/>) instead of being stored wrongly or failing in SQL.
/// </summary>
internal static SqlParameter AmountParam(string name, object? raw, byte scale)
{
decimal? value = ParseAmount(raw);
if (value is { } v && Math.Abs(v) > MaxAmount)
throw new InvoiceAmountOutOfRangeException(name, v);
return new SqlParameter(name, System.Data.SqlDbType.Decimal)
{
Precision = 10,
Scale = scale,
Value = value.HasValue ? value.Value : DBNull.Value
};
}
/// <summary>
/// Reads an amount the draft session or editor supplied: numbers are taken as-is; strings may be
/// German ("-1234,56") or invariant ("-1234.56", "-1.5E-05") — a comma marks German formatting.
/// The result is rounded to cents. Empty / "NaN" / unparseable → null (stored as NULL).
/// </summary>
internal static decimal? ParseAmount(object? raw)
{
if (raw is JValue jv) raw = jv.Value;
decimal d;
switch (raw)
{
case null: return null;
case decimal m: d = m; break;
case double dbl when double.IsFinite(dbl): d = (decimal)dbl; break;
case float flt when float.IsFinite(flt): d = (decimal)flt; break;
case int or long or short or byte: d = Convert.ToDecimal(raw, CultureInfo.InvariantCulture); break;
case double or float: return null;
default:
string s = (Convert.ToString(raw, CultureInfo.InvariantCulture) ?? "").Trim();
var culture = s.Contains(',') ? GermanCulture : CultureInfo.InvariantCulture;
if (!decimal.TryParse(s, NumberStyles.Float, culture, out d)) return null;
break;
}
return Math.Round(d, 2, MidpointRounding.AwayFromZero);
}
private static readonly CultureInfo GermanCulture = new("de-DE");
/// <summary> /// <summary>
/// Builds the InvoiceOptions CSV from the posted admin flags. /// Builds the InvoiceOptions CSV from the posted admin flags.
/// <c>§13b</c> is the existing reverse-charge flag; <c>setmode:&lt;mode&gt;</c> /// <c>§13b</c> is the existing reverse-charge flag; <c>setmode:&lt;mode&gt;</c>
@@ -253,6 +304,14 @@ public class FdsInvoiceData
} }
} }
/// <summary>An invoice amount exceeds what the invoice procs' <c>numeric(10,x)</c> parameters can store.</summary>
public sealed class InvoiceAmountOutOfRangeException(string parameter, decimal value)
: Exception($"Invoice amount {parameter} = {value.ToString(CultureInfo.InvariantCulture)} is outside ±{FdsInvoiceData.MaxAmount.ToString(CultureInfo.InvariantCulture)}.")
{
public string Parameter { get; } = parameter;
public decimal Value { get; } = value;
}
/// <summary>A service-request group as it renders on the invoice: a heading plus its line items.</summary> /// <summary>A service-request group as it renders on the invoice: a heading plus its line items.</summary>
public sealed class InvoiceBlock public sealed class InvoiceBlock
{ {
-1
View File
@@ -32,7 +32,6 @@ public class FdsReminderData
internal string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? ""; internal string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
// -- Computed props from registration ------------------------------------- // -- Computed props from registration -------------------------------------
public DateTime? DateCreated => ReminderRegistration?.getString("DateCreated") is { Length: > 0 } d ? DateTime.Parse(d) : null;
public string ReminderType => ReminderRegistration?.getString("type").Substr(0, 1) ?? ""; public string ReminderType => ReminderRegistration?.getString("type").Substr(0, 1) ?? "";
public string UserNameFinalized => ReminderRegistration?.getString("UserNameFinalized") ?? ""; public string UserNameFinalized => ReminderRegistration?.getString("UserNameFinalized") ?? "";
public string UserEmailFinalized => ReminderRegistration?.getString("UserEmailFinalized") ?? ""; public string UserEmailFinalized => ReminderRegistration?.getString("UserEmailFinalized") ?? "";
+17 -29
View File
@@ -153,25 +153,6 @@ public class Fuchs_intranet
return val.Exception != "" ? -1 : val.Result; return val.Exception != "" ? -1 : val.Result;
} }
// -- Logging ---------------------------------------------------------------
public void debug_log(string procedure, Exception? ex = null, string authuser = "", object? data = null)
{
try
{
var pl = new List<SqlParameter>
{
SQL_VarChar("@CodeReference", procedure.Left(200)),
SQL_NVarChar("@ExceptionMessage", ex?.Message ?? "", dbNull_IfEmpty: true),
SQL_NVarChar("@StackTrace", ex?.StackTrace ?? "", dbNull_IfEmpty: true),
SQL_NVarChar("@Data", data != null ? Newtonsoft.Json.JsonConvert.SerializeObject(data) : "", dbNull_IfEmpty: true)
};
string dbEx = ""; int? dbCode = null;
setSQLValue("EXECUTE [dbo].[fds__admin_logdebug] @CodeReference, @ExceptionMessage, @StackTrace, @Data;",
Intranet_SqlCon(), ref dbEx, ref dbCode, pl, Security: GetDbSecurity(authuser));
}
catch { /* swallow logging errors */ }
}
// -- PDF license ----------------------------------------------------------- // -- PDF license -----------------------------------------------------------
public static void SetPdfLicense() public static void SetPdfLicense()
{ {
@@ -219,12 +200,11 @@ public class FuchsUserIdentity
// --------------------------- SQL options ------------------------------------- // --------------------------- SQL options -------------------------------------
/// <summary> /// <summary>
/// Fuchs-specific SQL options — logs every SQL error both to the app's structured /// Fuchs-specific SQL options — logs every SQL error through the app's structured
/// <see cref="ILogger"/>/OpenTelemetry pipeline and to the <c>fds__admin_logdebug</c> SQL /// <see cref="ILogger"/> pipeline (which, at Warning+, also lands in <c>fds__debuglog</c> via
/// table (via <see cref="Fuchs_intranet.debug_log"/>). Handlers that don't separately inspect /// the SQL log sink — see ADR 0024). Handlers that don't separately inspect the result's
/// the result's <c>.Exception</c> would otherwise return an empty/200 response on a failing /// <c>.Exception</c> would otherwise return an empty/200 response on a failing stored procedure
/// stored procedure with zero application-log signal — the DB table alone requires someone to /// with zero log signal.
/// go looking for it.
/// </summary> /// </summary>
public class FIS_SQLOptions : sqloptions public class FIS_SQLOptions : sqloptions
{ {
@@ -232,10 +212,18 @@ public class FIS_SQLOptions : sqloptions
{ {
OnError = (procedure, ex, data) => OnError = (procedure, ex, data) =>
{ {
logger?.LogError(ex, "SQL error in {Procedure}: {Message} — context={@Context}", // OCORE hands the failing statement in `data` (new { sql = ... }); `context` is the
procedure, ex.Message, context); // caller's fn/id/code. Both are needed to attribute a timeout in the log — logging only
try { FuchsOcmsIntranet.Instance.debug_log($"SQL Error in {procedure}", ex, data: context); } // the (usually null) context left every "SQL Error in getSQLDataSet" row without the
catch { } // SQL that timed out. Falls back to the static OCORE.Logging.Logger (set once in
// Program.cs) when no request/service-scoped logger was supplied — several callers
// construct `new FIS_SQLOptions()` with neither logger nor context, and those must
// still reach the log, not silently drop it. EventId.Name keeps the exact
// "SQL Error in {procedure}" CodeReference the DB log used before this pipeline existed.
var lg = logger ?? OCORE.Logging.Logger;
lg?.LogError(new EventId(0, $"SQL Error in {procedure}"), ex,
"SQL error in {Procedure}: {Message} — sql={Sql} context={Context}",
procedure, ex.Message, data, context);
}; };
} }
} }

Some files were not shown because too many files have changed in this diff Show More