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:
co-authored by
Claude Opus 4.8
parent
e53d8962ad
commit
af445c015e
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user