Move the invoice draft editor to a backend single source of truth: an in-memory InvoiceDraftSession (per-token, cached) holds the editable payload, server-computed sums/VAT and validation, plus an automatic change history. The browser posts single edits; the server recomputes and signals the editing session over a dedicated SignalR hub (DraftPreviewHub) to re-fetch. This reverses the previously-documented stateless editor (EVAL_live_invoice_editing, INVOICE_LIFECYCLE §10), by explicit product decision — captured in ADR 0006 and 0007 plus the live-draft-editing concept doc. Backend (this milestone): - InvoiceDraftSession + ChangeHistoryEntry data holders - InvoiceDraftCalculator: pure port of quantChange/invSumUpdate (§13b, VAT-by-rate) and consistency checks — fully unit-tested - IInvoiceDraftCache/InvoiceDraftCache: in-memory store with idle sliding TTL - IInvoiceDraftService/InvoiceDraftEditService: open (payload or DB reload), patch, build state, flush via existing RegisterInvoiceAsync (no new persistence), preview from cache, discard (DB reload), history - InvoiceDraftExpiryService: pre-expiry warning + eviction-with-reason - DraftPreviewHub + IDraftNotifier/DraftNotifier: targeted draftReady/draftExpiring/ draftClosed signals per draft token - inv/dopen|dstate|dpatch|dpreview|dsave|dhistory|ddiscard|dclose endpoints; save reports success/failure via the existing EventService - DI + hub mapping in Program.cs Frontend (additive foundation): $fis.draft SignalR client for /draftpreview. The editor DOM inversion (routing deltas, rendering from server state) is the next, separately-verified step; existing endpoints are unaffected. Tests: 30 new (calculator, cache, expiry, patch/history, flush); 306 total passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5.8 KiB
Evaluation — Backend-cached invoice editing over SignalR
⚠️ Superseded (2026-07-10). This note's recommendation (keep the editor stateless; do not build the SignalR/server-cached model) was reversed by the product owner. Invoice draft editing is now backend-authoritative over an in-memory cache — see ADR
Decisions/0006-backend-authoritative-draft-editing.md,Decisions/0007-targeted-draft-signalr-groups.mdand the concept docConcepts/live-draft-editing.md. The analysis below is retained for the historical rationale and the risks it flagged (server-held state, scaling/backplane, reconnect) — which the new design addresses or accepts explicitly as documented limitations.
Idea (as proposed): hold invoices that users are editing in a server-side cache, keep a SignalR / WebSocket connection open, apply each front-end change in the backend, and push the recomputed state back to the browser. The backend becomes the single source of truth for the in-progress invoice.
This note evaluates that against the current design and recommends a path.
1. How invoice editing works today
- Stateless. The browser holds the editor state. It posts the whole
invcJSON toreq/sprep|sedit|save; the server computes/persists and returns a PDF preview (image collection). No per-user editing state lives on the server. - Totals/§13b/§35a and now set pricing are computed from posted data; the invoice total comes from the registration balance, not summed lines.
- Auth is cookie-based (
OCORECookieAuthenticationEvents), SQL-first data access, controller is stateless and DI-scoped.
Implication: the server is horizontally scalable and crash-tolerant for editing — there is nothing to lose if a node restarts mid-edit.
2. What the proposal would buy
| Benefit | Real for Fuchs? |
|---|---|
| Server-authoritative calculation (one place for pricing/VAT/set rules) | Partly already true — the PDF/totals are server-computed; the editor only previews. The duplicated logic is the live line math in JS. |
| Real-time multi-user co-editing | Low value — invoices are edited by one back-office user at a time; concurrent editing of the same draft is rare. |
| Live validation / instant recompute without full round-trips | Some value — smoother UX than re-posting the whole invc for each tweak. |
| Reduced payload (deltas vs whole invoice) | Marginal — invoices are small. |
3. Costs and risks
- Server-side edit state. Per-user/per-draft cache with lifetime management (idle expiry, explicit discard, max size), or the server leaks memory. Needs a distributed cache if scaled out (Redis), or sticky sessions for SignalR.
- Concurrency/locking. Two tabs or two users on the same draft → need optimistic concurrency / locking semantics that don't exist today.
- Connection lifecycle. Reconnect, replay, and "lost update" handling; offline/flaky networks; auth on the socket (cookie works, but token refresh and disconnect-on-logout must be handled).
- Scaling. SignalR with multiple instances needs a backplane (Redis/Azure SignalR). Today the app has none.
- Big rewrite of the editor. The 1,200-line
fis.inv_shared.jsbecomes an event-driven client of server state — a substantial, risky rewrite of working code, with new failure modes (desync between optimistic UI and server truth). - Testing surface grows (connection states, races) far beyond the current request/response model.
For a single-tenant back-office app with one editor at a time, this is a lot of accidental complexity for modest UX gains.
4. Recommendation
Do not do a full SignalR rewrite now. It optimises a problem (real-time collaboration, server-held edit state) the business doesn't strongly have, while adding stateful-server, scaling, and reconnection complexity to an app that is currently simple and robust.
Prefer an incremental, lower-risk path that captures most of the value:
-
Single source of pricing truth (highest value, do first). Add a stateless endpoint
inv/calcthat takes theinvcJSON and returns the computed lines + totals + set-mode resolution using the same backend code (InvoiceSetPricing, VAT, §13b/§35a). The editor calls it on change (debounced) to recompute, instead of duplicating the math in JS. This removes the front/back duplication — the actual pain — without sockets or server state. It also makes the interplay fully unit-testable (the endpoint is pure). -
Keep preview as-is (post → PDF image) but allow it to reuse
inv/calc. -
If/when live UX is still wanted, add SignalR only as a transport on top of the stateless calc (push recompute results), keeping persistence stateless. Defer server-held draft state until genuine multi-user co-editing is required.
-
If server-held drafts are truly needed, scope a pilot: one entity (invoice draft),
IDistributedCache-backed, explicit acquire/release lock, idle TTL, and a reconnect/replay protocol — behind a feature flag, measured against the current flow before rollout.
Why this fits the codebase
It aligns with the just-completed DI service layer: the calc lives in
IInvoiceService/InvoiceSetPricing (already tested), reused by both the preview
and a future socket. We get "changes computed by the backend, reflected in the
frontend" — the stated goal — without turning a stateless, scalable web app
into a stateful real-time system prematurely.
Suggested next step: implement inv/calc (item 1) and migrate the editor's
line math to it; revisit SignalR only if real-time/co-editing becomes a real
requirement.