Add backend-authoritative invoice draft editing (ADR 0006/0007)

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>
This commit is contained in:
Stefan
2026-07-10 13:29:35 +02:00
co-authored by Claude Opus 4.8
parent e53d8962ad
commit af445c015e
26 changed files with 2121 additions and 6 deletions
+84
View File
@@ -0,0 +1,84 @@
---
status: Active
lastUpdated: 2026-07-10
applyTo:
- "Fuchs/Services/InvoiceDraft*"
- "Fuchs/Services/IInvoiceDraft*"
- "Fuchs/code/InvoiceDraftSession.cs"
- "Fuchs/code/InvoiceDraftCalculator.cs"
- "Fuchs/Notifications/DraftPreviewHub.cs"
- "Fuchs/Notifications/*DraftNotifier*"
- "Fuchs/Controllers/IntranetController.InvoiceDraft.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 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), and `Validate`
(email/address/items/VAT-rate/negative-total checks). Being pure, it is exhaustively
unit-tested.
- **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.
## 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)
@@ -0,0 +1,88 @@
---
status: Accepted
date: 2026-07-10
applyTo:
- "Fuchs/Services/InvoiceDraft*"
- "Fuchs/Services/IInvoiceDraft*"
- "Fuchs/code/InvoiceDraftSession.cs"
- "Fuchs/code/InvoiceDraftCalculator.cs"
- "Fuchs/Notifications/DraftPreviewHub.cs"
- "Fuchs/Notifications/*DraftNotifier*"
- "Fuchs/Controllers/IntranetController.InvoiceDraft.cs"
- "Fuchs/js/intranet/**"
supersededBy: ""
---
# 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.** `InvoiceDraftCalculator` is 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's `sums`.
- **Commands are ordinary POSTs; signals are SignalR.** The editor posts single edits
to `inv/dpatch` (and `dopen`/`dstate`/`dpreview`/`dsave`/`dhistory`/`ddiscard`/`dclose`).
The server mutates the session, recomputes, validates, bumps a version, and pings the
editing browser (`draftReady`) to re-fetch `inv/dstate`. See
[0007](0007-targeted-draft-signalr-groups.md) 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. `dsave`
flushes the session to the DB by reusing the existing
`IInvoiceService.RegisterInvoiceAsync`**no new persistence path** — and reports
success/failure through the existing `IEventService` (ADR 0001). Finalise continues
through `req/sconf`.
- **Preview from cache.** `inv/dpreview` renders 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/dhistory` exposes it for the "Änderungshistorie"
dialog.
- **Idle lifecycle with user warning.** `InvoiceDraftExpiryService` warns 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 under
`Fuchs: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.
- `FdsInvoiceData` stays a pure data holder; `InvoiceDraftSession` is 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.md` and `INVOICE_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.
@@ -0,0 +1,59 @@
---
status: Accepted
date: 2026-07-10
applyTo:
- "Fuchs/Notifications/DraftPreviewHub.cs"
- "Fuchs/Notifications/IDraftNotifier.cs"
- "Fuchs/Notifications/DraftNotifier.cs"
- "Fuchs/Program.cs"
- "Fuchs/js/intranet/**"
supersededBy: ""
---
# 0007 — Draft-editing signals are targeted via a dedicated hub with per-draft groups
## Context
Backend-authoritative draft editing (ADR 0006) needs to notify **exactly the one
browser** editing a given draft that its cached state changed, is about to expire, or
was closed. The existing `NotificationHub` (ADR 0002) deliberately **broadcasts** every
business toast to all logged-in sessions and explicitly deferred per-user/targeted
delivery as "a new decision". Draft coordination pings are high-frequency, per-editor,
and must not spray to every session.
## Decision
Draft signals use a **dedicated** SignalR hub, `DraftPreviewHub`, mapped at
`/draftpreview` (separate from `NotificationHub` at `/notifications`). Targeting is by
**SignalR group named after the draft's session token**:
- The client calls the hub methods `JoinDraft(token)` / `LeaveDraft(token)` to
subscribe/unsubscribe its connection to a draft's group. The hub carries **no
commands** — only group membership (edits are POSTs; see ADR 0006).
- The server sends via `IDraftNotifier` (`DraftNotifier`) to `Clients.Group(token)`:
`draftReady{token,version}` (re-fetch), `draftExpiring{token,secondsLeft}` (idle
warning), `draftClosed{token,reason}` (session evicted/discarded → close the editor).
- Like `EventService`, delivery failures are logged and swallowed — a missed
coordination ping must never fail the underlying operation; the client also re-syncs on
reconnect and on its next POST.
Business success/failure messages for draft operations (e.g. "Zwischenstand
gespeichert") continue to flow through `IEventService`/`NotificationHub`, **not** this
hub — the two channels stay separate.
## Consequences
- The session **token doubles as the group name**; it is an opaque GUID and must not
encode sensitive data. Any browser that knows a token can join its group, so tokens
must be treated as capabilities and only handed to the authenticated editor that opened
the draft.
- Adding a new draft signal means adding a method to `IDraftNotifier` + `DraftNotifier`
and a client handler in `$fis.draft` — not overloading the business notification path.
- ADR 0002 is unchanged: `NotificationHub` stays broadcast-only for toasts. This hub is
the answer to its "if per-user targeting becomes necessary, that is a new decision".
- Multi-instance scale-out needs a SignalR backplane for group delivery — same limitation
as ADR 0006.
## Alternatives considered
- **Reuse `NotificationHub` with groups**: rejected — it would entangle broadcast toasts
with targeted, high-frequency editing pings and force ADR 0002's broadcast contract to
change. A separate hub keeps the concerns and their decisions independent.
- **Per-user groups (by account id)**: rejected — a user may open two drafts/tabs;
per-draft-token groups target the precise editor and naturally support that.
+12
View File
@@ -1,5 +1,17 @@
# 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/0006-backend-authoritative-draft-editing.md)**,
> [`Decisions/0007-targeted-draft-signalr-groups.md`](Decisions/0007-targeted-draft-signalr-groups.md)
> and the concept doc [`Concepts/live-draft-editing.md`](Concepts/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.
+12 -3
View File
@@ -337,9 +337,18 @@ flowchart TD
## 10. Key invariants worth remembering
- **Stateless editor**: every preview/save/finalise call re-posts the full
`invc` JSON; the server never holds a partial invoice in memory or session
between requests (see `EVAL_live_invoice_editing.md`).
> **⚠️ Updated (2026-07-10):** the "stateless editor" invariant below describes the
> **legacy** draft-editing flow. Invoice draft editing is being moved to a
> **backend-authoritative** model where the server holds the draft in an in-memory
> cache (the single source of truth), the browser posts single edits and re-fetches on
> a SignalR signal, and totals are computed server-side. See ADR
> [`Decisions/0006-backend-authoritative-draft-editing.md`](Decisions/0006-backend-authoritative-draft-editing.md)
> and [`Concepts/live-draft-editing.md`](Concepts/live-draft-editing.md). Finalise/email
> (§5–§6) are unchanged. The remaining invariants below still hold.
- **Stateless editor** *(legacy — see the note above; superseded by ADR 0006)*: every
preview/save/finalise call re-posts the full `invc` JSON; the server never holds a
partial invoice in memory or session between requests (see `EVAL_live_invoice_editing.md`).
- **Totals come from the registration, not the rendered lines**: `sms.ttn`
/`sms.ttb` (posted) become `InvoiceBalance`/`InvoiceBalance_net`; display
mode (set pricing) never changes what the customer owes.