Make invoice/reminder live-draft edits concurrency-safe with an explicit
DraftFlushResult, add the fds__setReminder stored procedure, and harden the
date helper functions. Includes concurrency and SQL contract tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 were the pilot;
reminders now mirror the same design (see "Reminders" below).
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:
RecomputeLineValues (net/VAT/service-net/service-VAT per line from raw quantity ×
price × VAT rate, the quantChange/setVat port), RecomputeTotals
(the invSumUpdate/csms aggregation + §13b reverse-charge), RecomputePositions
(numbers every line except heading/free-text lines continuously across the whole invoice —
mirroring the editor's invSumUpdate, so the editor and the PDF show identical Pos. numbers,
including after a reorder),
and Validate (email/address/items/VAT-rate/negative-total checks). Being pure, it is
exhaustively unit-tested. The online editor performs no arithmetic of any kind — not
header, footer, sums, totals, taxes, nor a single line's own net/VAT value: $inv.quantChange
and $inv.setVat only post the raw field the user changed (qty/price/vat rate), and
$inv.invSumUpdate only reassembles the row-contract array needed to post req — it computes
no totals, no VAT breakdown, and no service-refund note figures. All of those are rendered
exclusively from dstate.sums via $inv.d.footer.
Sanitisation & reorder. Scalar text deltas (title/email/address/provisionperiod/
provisionlocation) and the section heading (block.replace) are stripped of the editor's
TinyMCE HTML (<p>…</p>, <br>) to plain text in ApplyDelta (HtmlToPlain) — the backend
is the single source of truth, so no HTML reaches the DB, the PDF or a reloaded draft. Section
drags post a block.order delta (["id",…]) that reorders Req; positions are then
renumbered and pushed back via the view state (applyState/applyPositions). The change
history records the changed field (e.g. the new heading text), never the whole block JSON.
The PDF (FuchsPdf) renders a heading row per block (FdsInvoiceData.InvoiceBlocks) and shows
every position's price (set members are priced like standalone lines; only setonly collapses
them), so the PDF preview mirrors the online editor.
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.
Set structure (ADR 0016). Every cached line carries a parent link (SetItmId → the
owning typ == "set" line, null/self = top level); sets may nest. fds__prepInvoice's
implicit tagging is the initial structure; the user edits it in the "Set-Struktur
bearbeiten" dialog ($inv.sets, fis.inv_sets.js) which posts one delta per action —
set.assign { parent } (drop onto a set / pull out), set.level ±1 (indent under the
preceding sibling set / outdent to the grandparent), set.create { typ: set|text, title,
parent }, set.move ±1 | { anchor, where } (reorder among siblings / drag before or after
a row; the subtree travels along). InvoiceDraftSetStructureDeltas delegates to the pure
InvoiceSetStructure (assign / change level / create / place, then linearise the block depth-first and mirror
setId/level onto the items contract). Refresh publishes lvl on every line
(RecomputeLevels), rendered as an indent class in the editor and a per-level indent in the
PDF. item.setprice sums a header's transitive descendants. Structure deltas never
change values — only order, membership, levels and therefore position numbers.
Concurrency. The editor fires patches, state fetches, previews and saves for one token
concurrently, and the session payload (JObject/JArray, the history list) is not thread-safe.
Every read and write of the payload runs under the session's SyncRoot, and everything the
controller serialises afterwards (BuildState, GetHistory, the FdsInvoiceData a preview or
flush uses) is a deep-cloned snapshot taken under that lock — never a live reference. Flushes are
additionally serialised per session on FlushGate (a SemaphoreSlim, awaited — SyncRoot
cannot be held across the registration's await): the create/update decision in
FlushToDbAsync is read-then-write across that await, so two overlapping saves of a
not-yet-registered draft (double-click on Zwischenspeichern, or Zwischenspeichern followed
by preview → confirm) would otherwise both call fds__createInvoice and leave an orphaned
invoice. The waiting save snapshots after it acquires the gate, so it persists the newer
state as an update of the row the first save created. FlushToDbAsync reports that decision
(DraftFlushResult.Updated) and the controller publishes InvoiceDraftCreated/-Updated from
it rather than from the session state it saw before the flush. Patches are never blocked by a
running save. DraftSessionConcurrencyTests pins both the lock and the gate.
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 computes nothing — no
header, footer, sums, totals, taxes, or per-line values.
Fuchs/js/intranet/modules/fis.inv_sets.js — the set-structure dialog ($inv.sets), a view of dstate.req.
Reminders (Zahlungserinnerung)
Reminders mirror the same backend-authoritative model with a reminder-shaped session. A
reminder chases a single invoiced amount, so the machinery is simpler than an invoice's:
there are no line-item blocks, VAT grouping or reordering — just recipient fields and the
amount pair.
Endpoints are rem/d* (dopen/dstate/dpatch/dpreview/dsave/dhistory/dclose),
dispatched from Do_Process_Reminder. Finalise + email still runs through the existing
rem/conf (HandleReminderConf), exactly as invoices finalise through req/sconf.
Session (ReminderDraftSession) holds the editor's new (subject / invoiceaddress /
invoiceemail / text / amount / amount_payed / CustomValues) and rem (invid / type /
invoiceid / invoicedate) blocks, plus server-computed Sums (AmountTotal, AmountPayed,
AmountOpen). It reuses the shared ChangeHistoryEntry; validation uses
ReminderDraftValidationMessage.
Deltas (ReminderDraftDelta): scalar email/address/subject/text (HTML-sanitised via
the shared InvoiceDraftEditService.HtmlToPlain), the numeric amount/amount_payed
(normalised to an invariant decimal string), and contact (→ CustomValues).
Orchestration (ReminderDraftEditService, scoped) flushes to the DB by reusing
IReminderService.RegisterReminderAsync, and renders previews from a synthesised
ReminderRegistration (including the single-invoice invoices row the reminder PDF table
renders) so a preview needs no DB round-trip. A first save runs fds__createReminder; every
later save of the same session runs fds__setReminder, which updates the row in place (version
bump, same result shape) and refuses — @Id NULL → the batch throws → "nicht gespeichert" — once
the reminder is finalised or has a stored file. Flushes are serialised on the session's
FlushGate exactly like invoice flushes (see Concurrency above), so overlapping saves cannot
create two reminder rows either.
Cache/expiry (ReminderDraftCache singleton + ReminderDraftExpiryService) mirror the
invoice ones and share the same Fuchs:DraftEditing TTL config.
Signals reuse the shared DraftPreviewHub + IDraftNotifier unchanged — the token-keyed
groups serve invoice and reminder drafts alike.
Frontend ($inv.rd in fis.inv_shared.js) opens/joins on rem/dopen, posts one delta per
inline edit and per item-row amount change, renders the open-amount footer + validation from the
server state, and previews/finalises through rem/dpreview → rem/dsave → rem/conf. It shares
the invoice editor DOM; $inv.d and $inv.rd each key off their own token, so the shared inline
editor safely no-ops for whichever mode is inactive.