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