--- 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/`, 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__…` 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.