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 are the pilot;
reminders are intended to mirror the same design.
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 (<p>…</p>, <br>) 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.