Files
Fuchs_Intranet/Fuchs/Docs/Decisions/0025-invoice-document-regenerate-on-miss.md
T
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

3.9 KiB

status, date, applyTo, supersededBy
status date applyTo supersededBy
Accepted 2026-09-16
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

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 needed switching from setSQLValue_async (reports success once ExecuteNonQueryAsync completes without throwing, regardless of rows affected) to setSQLValue_async_result, whose AffectedRows makes a no-op UPDATE ... WHERE [file] IS NULL distinguishable from an actual write — this is what made the original incident's failure 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.