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>
4.9 KiB
status, date, applyTo, supersededBy
| status | date | applyTo | supersededBy | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| Accepted | 2026-07-10 |
|
0006 — Invoice draft editing is backend-authoritative over an in-memory cache
Context
The invoice editor was deliberately stateless: the browser held the working
model, computed totals/VAT client-side (invSumUpdate in fis.inv_shared.js) and
re-posted the whole invc JSON on every preview/save. EVAL_live_invoice_editing.md
(2026) recommended keeping it that way and against a server-cached, SignalR-driven
model, because the real-time/co-editing benefits were weak for a single back-office
editor.
The product owner has since decided the trade-off differently and prioritised a single source of truth in the backend with server-computed sums, server-side plausibility/consistency checks, in-place PDF preview without re-upload, an automatic change history, and an explicit discard. This decision records that reversal and the architecture chosen to implement it.
Decision
While a user edits an invoice draft, the authoritative state lives server-side in
an in-memory InvoiceDraftSession (Fuchs/code/InvoiceDraftSession.cs), held by the
singleton IInvoiceDraftCache and orchestrated by the scoped IInvoiceDraftService
(InvoiceDraftEditService). The browser is a pure view/input layer.
- Truth & calculation on the server.
InvoiceDraftCalculatoris the pure, unit-tested port of the former client-side math (quantChange+invSumUpdate), including the §13b reverse-charge rule and VAT-per-rate grouping. The browser never computes totals; it renders the server'ssums. - Commands are ordinary POSTs; signals are SignalR. The editor posts single edits
to
inv/dpatch(anddopen/dstate/dpreview/dsave/dhistory/ddiscard/dclose). The server mutates the session, recomputes, validates, bumps a version, and pings the editing browser (draftReady) to re-fetchinv/dstate. See 0007 for the targeted-signal transport. - Cache-only until Zwischenspeichern/Finalise. Opening builds the session (from a
brand-new payload or by reloading a DB draft); edits touch only the cache.
dsaveflushes the session to the DB by reusing the existingIInvoiceService.RegisterInvoiceAsync— no new persistence path — and reports success/failure through the existingIEventService(ADR 0001). Finalise continues throughreq/sconf. - Preview from cache.
inv/dpreviewrenders the draft PDF straight from the session (synthesised registration), with no client upload. - Automatic change history. Every applied patch appends a
ChangeHistoryEntry(cache-only, never persisted);inv/dhistoryexposes it for the "Änderungshistorie" dialog. - Idle lifecycle with user warning.
InvoiceDraftExpiryServicewarns the editing browser before a session's idle TTL lapses (draftExpiring) and, on eviction, tells it to close the editor with a reason (draftClosed). TTL and warning lead are underFuchs:DraftEditing.
Consequences
- The server is now stateful for in-progress drafts. This is acceptable for a single-instance deployment; scale-out requires sticky sessions or a distributed cache/SignalR backplane — none exist today, so this is a documented limitation, not a silent assumption.
- New editor interactions must be modelled as a delta applied server-side (add a
case in
InvoiceDraftEditService.ApplyDelta+ calculator handling), never as a new client-side calculation. Do not reintroduce client-side totals. FdsInvoiceDatastays a pure data holder;InvoiceDraftSessionis likewise a data holder, with all logic in the service/calculator (mirrors the existing service split).- Reminders (Mahnungen) are intended to follow the identical pattern as a second phase; this decision covers invoices first (the pilot) and applies to the reminder mirror when built.
EVAL_live_invoice_editing.mdandINVOICE_LIFECYCLE.md§4/§10 (the "stateless editor" invariant) are superseded by this decision for the draft-editing flow and have been annotated accordingly.
Alternatives considered
- Keep the stateless editor (the prior recommendation): rejected by the product owner in favour of a backend single source of truth.
- Full bidirectional SignalR hub for commands too: rejected — edits as POSTs reuse the existing controller/auth pattern and avoid a command reconnect/replay protocol; the hub carries only coordination signals.
- Write-through to the DB on every edit: rejected — conflicts with the "Zwischenspeichern = persist the cache" semantics and adds DB load; the cache is the truth until an explicit save/finalise.