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

79 lines
3.9 KiB
Markdown

---
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 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.