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
@@ -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.