--- status: Active lastUpdated: 2026-07-10 applyTo: - "Fuchs/Services/InvoiceDraft*" - "Fuchs/Services/IInvoiceDraft*" - "Fuchs/Services/ReminderDraft*" - "Fuchs/Services/IReminderDraft*" - "Fuchs/code/InvoiceDraftSession.cs" - "Fuchs/code/InvoiceDraftCalculator.cs" - "Fuchs/code/ReminderDraftSession.cs" - "Fuchs/code/ReminderDraftCalculator.cs" - "Fuchs/Notifications/DraftPreviewHub.cs" - "Fuchs/Notifications/*DraftNotifier*" - "Fuchs/Controllers/IntranetController.InvoiceDraft.cs" - "Fuchs/Controllers/IntranetController.ReminderDraft.cs" - "Fuchs/js/intranet/**" relatedDecisions: - "0006-backend-authoritative-draft-editing.md" - "0007-targeted-draft-signalr-groups.md" --- # Live draft editing (backend-authoritative invoice previews) ## Summary While a back-office user edits an invoice draft, the authoritative state is held in server memory, not in the browser. The browser posts single edits, the server mutates the cached record, recomputes totals/VAT and re-validates, then pushes a "state changed" signal so the browser re-fetches and re-renders. This makes the backend the single source of truth (server-computed sums, consistency checks, in-place PDF preview, change history, explicit discard), reversing the earlier stateless editor. Invoices were the pilot; reminders now mirror the same design (see "Reminders" below). ## How it works ``` Open: Browser --POST inv/dopen {id | payload}--> server builds InvoiceDraftSession, caches it Browser --SignalR JoinDraft(token)--> joins the draft's group; spinner while loading Browser --POST inv/dstate {token}--> renders admin/new/req + server sums + validation Edit: Browser --POST inv/dpatch {token, delta}--> mutate + recompute + validate + version++ Server --SignalR draftReady{token,version}--> Browser re-fetches inv/dstate, re-renders Preview: Browser --POST inv/dpreview {token}--> PDF rendered straight from the cache (no upload) Save: Browser --POST inv/dsave {token}--> flush cache->DB (RegisterInvoiceAsync) + EventService toast History: Browser --POST inv/dhistory {token}--> change list -> "Änderungshistorie" dialog Discard: Browser --POST inv/ddiscard {token}--> reload session from DB draft -> draftReady Close: Browser --POST inv/dclose {token}--> session removed (+ LeaveDraft) Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bitte zwischenspeichern" Server (evict) --SignalR draftClosed{token,reason}--> close the editor with a reason ``` - **Session** (`InvoiceDraftSession`) is a pure data holder: the editable payload as the exact editor JSON (`admin` / `new` / `req` blocks with `items`), plus server-computed `Sums`, `ValidationMessages`, `History`, `Version`, `Token`, `InvId`, `LastAccessUtc`. - **Calculation** (`InvoiceDraftCalculator`, static/pure) ports the former client math: `RecomputeItem` (quantity × price × VAT, the `quantChange` port), `RecomputeTotals` (the `invSumUpdate`/`csms` aggregation + §13b reverse-charge), `RecomputePositions` (numbers every line except heading/free-text lines continuously across the whole invoice — mirroring the editor's `invSumUpdate`, so the editor and the PDF show identical `Pos.` numbers, including after a reorder), and `Validate` (email/address/items/VAT-rate/negative-total checks). Being pure, it is exhaustively unit-tested. - **Sanitisation & reorder.** Scalar text deltas (`title`/`email`/`address`/`provisionperiod`/ `provisionlocation`) and the section heading (`block.replace`) are stripped of the editor's TinyMCE HTML (`

`, `
`) to plain text in `ApplyDelta` (`HtmlToPlain`) — the backend is the single source of truth, so no HTML reaches the DB, the PDF or a reloaded draft. Section drags post a `block.order` delta (`["id",…]`) that reorders `Req`; positions are then renumbered and pushed back via the view state (`applyState`/`applyPositions`). The change history records the **changed field** (e.g. the new heading text), never the whole block JSON. The PDF (`FuchsPdf`) renders a heading row per block (`FdsInvoiceData.InvoiceBlocks`) and shows every position's price (set members are priced like standalone lines; only `setonly` collapses them), so the PDF preview mirrors the online editor. - **Orchestration** (`InvoiceDraftEditService`, scoped) opens sessions (from a fresh payload or by reloading a DB draft via `fds__getInvoice`, reshaped like `BuildInvoiceRequestList`), applies deltas (`ApplyDelta`), builds the view-state DTO, flushes to the DB by reusing `IInvoiceService.RegisterInvoiceAsync` (no new persistence path), renders previews from a synthesised registration, and discards by reloading. - **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after, the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`. - **Signals** (`DraftPreviewHub` at `/draftpreview` + `IDraftNotifier`) are targeted at the editing browser via a group named after the session token: `draftReady`, `draftExpiring`, `draftClosed`. Business success/failure still flows through `IEventService`/`NotificationHub`. - **Frontend** (`$fis.draft` in `fis_main.js`, editor in `fis.inv_shared.js`) opens/joins, posts one delta per change, shows a loading state whenever awaiting a signal, and offers "Änderungen verwerfen" and "Änderungshistorie" menu actions. It no longer computes totals. ## Key files - `Fuchs/code/InvoiceDraftSession.cs` — session + `ChangeHistoryEntry` + `InvoiceDraftSums`. - `Fuchs/code/InvoiceDraftCalculator.cs` — pure recompute + validate. - `Fuchs/Services/InvoiceDraftCache.cs` / `IInvoiceDraftCache.cs` — in-memory store + TTL. - `Fuchs/Services/InvoiceDraftEditService.cs` / `IInvoiceDraftService.cs` — orchestration + delta contract. - `Fuchs/Services/InvoiceDraftExpiryService.cs` — idle warn/evict monitor. - `Fuchs/Notifications/DraftPreviewHub.cs`, `DraftNotifier.cs`, `IDraftNotifier.cs` — targeted signals. - `Fuchs/Controllers/IntranetController.InvoiceDraft.cs` — `inv/d*` endpoints. - `Fuchs/js/intranet/fis_main.js`, `Fuchs/js/intranet/modules/fis.inv_shared.js` — client. ## Reminders (Zahlungserinnerung) Reminders mirror the same backend-authoritative model with a reminder-shaped session. A reminder chases a single invoiced amount, so the machinery is simpler than an invoice's: there are no line-item blocks, VAT grouping or reordering — just recipient fields and the amount pair. - **Endpoints** are `rem/d*` (`dopen`/`dstate`/`dpatch`/`dpreview`/`dsave`/`dhistory`/`dclose`), dispatched from `Do_Process_Reminder`. Finalise + email still runs through the existing `rem/conf` (`HandleReminderConf`), exactly as invoices finalise through `req/sconf`. - **Session** (`ReminderDraftSession`) holds the editor's `new` (subject / invoiceaddress / invoiceemail / text / amount / amount_payed / CustomValues) and `rem` (invid / type / invoiceid / invoicedate) blocks, plus server-computed `Sums` (`AmountTotal`, `AmountPayed`, `AmountOpen`). It reuses the shared `ChangeHistoryEntry`; validation uses `ReminderDraftValidationMessage`. - **Calculation** (`ReminderDraftCalculator`, static/pure): `AmountOpen = AmountTotal − AmountPayed`, plus email/address/subject/open-amount plausibility checks. Exhaustively unit-tested. - **Deltas** (`ReminderDraftDelta`): scalar `email`/`address`/`subject`/`text` (HTML-sanitised via the shared `InvoiceDraftEditService.HtmlToPlain`), the numeric `amount`/`amount_payed` (normalised to an invariant decimal string), and `contact` (→ `CustomValues`). - **Orchestration** (`ReminderDraftEditService`, scoped) flushes to the DB by reusing `IReminderService.RegisterReminderAsync`, and renders previews from a synthesised `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 (there is no `fds__setReminder` update proc), so a re-saved reminder draft does not update the prior DB row — the primary flow (preview → confirm) flushes once immediately before finalising. - **Cache/expiry** (`ReminderDraftCache` singleton + `ReminderDraftExpiryService`) mirror the invoice ones and share the same `Fuchs:DraftEditing` TTL config. - **Signals** reuse the shared `DraftPreviewHub` + `IDraftNotifier` unchanged — the token-keyed groups serve invoice and reminder drafts alike. - **Frontend** (`$inv.rd` in `fis.inv_shared.js`) opens/joins on `rem/dopen`, posts one delta per inline edit and per item-row amount change, renders the open-amount footer + validation from the server state, and previews/finalises through `rem/dpreview` → `rem/dsave` → `rem/conf`. It shares the invoice editor DOM; `$inv.d` and `$inv.rd` each key off their own token, so the shared inline editor safely no-ops for whichever mode is inactive. ### Reminder key files - `Fuchs/code/ReminderDraftSession.cs` — session + `ReminderDraftSums` + `ReminderDraftValidationMessage`. - `Fuchs/code/ReminderDraftCalculator.cs` — pure open-amount recompute + validate. - `Fuchs/Services/ReminderDraftCache.cs` / `IReminderDraftCache.cs` — in-memory store + TTL. - `Fuchs/Services/ReminderDraftEditService.cs` / `IReminderDraftService.cs` — orchestration + delta contract. - `Fuchs/Services/ReminderDraftExpiryService.cs` — idle warn/evict monitor. - `Fuchs/Controllers/IntranetController.ReminderDraft.cs` — `rem/d*` endpoints. ## Related decisions - [0006 — Backend-authoritative draft editing](../Decisions/0006-backend-authoritative-draft-editing.md) - [0007 — Targeted draft SignalR groups](../Decisions/0007-targeted-draft-signalr-groups.md)