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
+193
View File
@@ -0,0 +1,193 @@
using System.Globalization;
using Newtonsoft.Json.Linq;
namespace Fuchs.intranet;
/// <summary>
/// Server-side, pure port of the invoice totals/VAT math that used to live in the
/// browser (<c>quantChange</c> + <c>invSumUpdate</c> in <c>fis.inv_shared.js</c>).
/// This is the authoritative calculation for a live draft (ADR 0006): given the
/// editable payload of an <see cref="InvoiceDraftSession"/>, it (re)computes each
/// item's line values, aggregates block/rate totals into
/// <see cref="InvoiceDraftSession.Sums"/>, and runs the plausibility/consistency
/// checks into <see cref="InvoiceDraftSession.ValidationMessages"/>.
///
/// Kept static and free of I/O so it is exhaustively unit-testable — the payoff the
/// old <c>EVAL_live_invoice_editing.md</c> predicted once the truth moved server-side.
/// </summary>
public static class InvoiceDraftCalculator
{
/// <summary>
/// Re-derives a single item's line values from quantity × net price × VAT rate —
/// the port of the editor's <c>quantChange</c>. Only applied when an item's
/// quantity/price actually changes (osum/set/text lines keep their stored values,
/// exactly as the client only ran <c>quantChange</c> on edited quantity rows).
/// Mirrors the guard <c>qty &gt; 0 &amp;&amp; price &gt; 0</c>.
/// </summary>
public static void RecomputeItem(JObject item)
{
int qty = (int)Dec(item["quantityhours"]);
decimal net = Dec(item["net"]);
decimal vat = RatePercent(Str(item["vat"])) * 0.01m; // "19%"/"19,0%" → 0.19
if (qty > 0 && net > 0)
{
decimal netVal = decimal.Round(qty * net, 2, MidpointRounding.AwayFromZero);
decimal vatVal = decimal.Round(qty * net * vat, 2, MidpointRounding.AwayFromZero);
item["net_val"] = netVal;
item["vat_val"] = vatVal;
if (string.Equals(Str(item["Type"]), "service", StringComparison.OrdinalIgnoreCase))
{
item["svcnet_val"] = netVal;
item["svcvat_val"] = vatVal;
}
}
}
/// <summary>
/// Aggregates all line items into the draft's totals — the port of <c>invSumUpdate</c>'s
/// <c>csms</c> accumulation plus the §13b reverse-charge rule (VAT suppressed → gross = net).
/// VAT is grouped by the item's rate string (matching the editor's <c>sms.vat</c> map).
/// </summary>
public static void RecomputeTotals(InvoiceDraftSession session)
{
var sums = new InvoiceDraftSums();
bool p13b = Flag(session.Admin, "p13b");
foreach (var blockTok in session.Req)
{
if (blockTok is not JObject block) continue;
decimal blockNet = 0;
string blockId = Str(block["Id"]);
if (block["items"] is JArray items)
{
foreach (var itemTok in items)
{
if (itemTok is not JObject item) continue;
decimal netVal = Dec(item["net_val"]);
decimal vatVal = Dec(item["vat_val"]);
decimal svcNet = Dec(item["svcnet_val"]);
decimal svcVat = Dec(item["svcvat_val"]);
sums.ServiceNet += svcNet;
sums.ServiceVat += svcVat;
sums.TotalNet += netVal;
sums.TotalVat += vatVal;
sums.TotalGross += netVal + vatVal;
blockNet += netVal;
string rate = NormalizeRate(Str(item["vat"]));
if (rate.Length > 0)
sums.VatByRate[rate] = sums.VatByRate.GetValueOrDefault(rate) + vatVal;
}
}
if (!string.IsNullOrEmpty(blockId))
sums.NetByBlock[blockId] = sums.NetByBlock.GetValueOrDefault(blockId) + blockNet;
}
if (p13b)
{
// Reverse-charge: no VAT lines, gross equals net (mirrors invSumUpdate's else-branch).
sums.TotalGross = sums.TotalNet;
sums.TotalVat = 0;
sums.VatByRate.Clear();
}
session.Sums = sums;
}
/// <summary>
/// Refreshes the draft's plausibility / consistency findings. "error" severity marks
/// issues that should block a clean finalise; "warning" is advisory. Kept in German,
/// user-readable, so the frontend can render them directly.
/// </summary>
public static void Validate(InvoiceDraftSession session)
{
session.ValidationMessages.Clear();
void Add(string field, string sev, string msg) =>
session.ValidationMessages.Add(new InvoiceDraftValidationMessage(field, sev, msg));
// Recipient email
string email = Str(session.New["invoiceemail"]).Trim();
if (email.Length == 0)
Add("email", "warning", "Es ist keine E-Mail-Adresse hinterlegt — die Rechnung kann nicht per E-Mail versandt werden.");
else if (!IsValidEmail(email))
Add("email", "error", "Die E-Mail-Adresse ist ungültig.");
// Recipient address
if (Str(session.New["invoiceaddress"]).Trim().Length == 0)
Add("address", "warning", "Es ist keine Rechnungsanschrift hinterlegt.");
// At least one priced line
if (!HasAnyItem(session))
Add("items", "error", "Die Rechnung enthält keine Positionen.");
// VAT rate sanity (only when not reverse-charge)
if (!Flag(session.Admin, "p13b"))
{
foreach (var rate in session.Sums.VatByRate.Keys)
if (!IsKnownVatRate(rate))
Add("vat", "warning", $"Ungewöhnlicher Umsatzsteuersatz: {rate}%.");
}
// Negative total
if (session.Sums.TotalGross < 0)
Add("total", "warning", "Der Rechnungsbetrag ist negativ.");
}
// ── helpers ──────────────────────────────────────────────────────────────
private static bool HasAnyItem(InvoiceDraftSession session)
{
foreach (var blockTok in session.Req)
if (blockTok is JObject block && block["items"] is JArray items && items.Count > 0)
return true;
return false;
}
/// <summary>Parses a JToken to a decimal, tolerating German ("12,50") and invariant ("12.50") strings and "%".</summary>
internal static decimal Dec(JToken? token)
{
if (token == null || token.Type == JTokenType.Null) return 0;
if (token.Type is JTokenType.Float or JTokenType.Integer) return token.Value<decimal>();
return FuchsPdf.ParseDec(Str(token), out decimal d) ? d : 0;
}
private static string Str(JToken? token) =>
token == null || token.Type == JTokenType.Null ? "" : token.Value<string>() ?? "";
private static bool Flag(JObject obj, string key)
{
var t = obj[key];
if (t == null || t.Type == JTokenType.Null) return false;
if (t.Type == JTokenType.Boolean) return t.Value<bool>();
string s = Str(t).Trim().ToLowerInvariant();
return s is "1" or "true" or "yes" or "ja" or "on";
}
/// <summary>Normalises a VAT rate string ("19,0%", "7%", "19") to a canonical numeric string ("19", "7").</summary>
internal static string NormalizeRate(string? raw)
{
string s = (raw ?? "").Replace("%", "").Trim().Replace(',', '.');
if (s.Length == 0) return "";
if (!double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out double d) || d == 0) return "";
return d == Math.Floor(d)
? ((long)d).ToString(CultureInfo.InvariantCulture)
: d.ToString(CultureInfo.InvariantCulture);
}
private static bool IsKnownVatRate(string rate) => rate is "0" or "7" or "19";
/// <summary>Parses a VAT rate string ("19%", "19,0%", "7") to its numeric percent (German/invariant tolerant).</summary>
internal static decimal RatePercent(string? raw)
{
string s = (raw ?? "").Replace("%", "").Trim().Replace(',', '.');
return decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal d) ? d : 0;
}
private static bool IsValidEmail(string email)
{
int at = email.IndexOf('@');
if (at <= 0 || at != email.LastIndexOf('@')) return false;
int dot = email.IndexOf('.', at);
return dot > at + 1 && dot < email.Length - 1;
}
}
+111
View File
@@ -0,0 +1,111 @@
using Newtonsoft.Json.Linq;
namespace Fuchs.intranet;
/// <summary>
/// Server-side, in-memory editing state for a single invoice draft — the
/// authoritative source of truth while a back-office user is editing a draft in
/// the browser (see ADR 0006). The browser is a pure view/input layer: it posts
/// single changes (<see cref="Fuchs.Services.InvoiceDraftDelta"/>), the server
/// mutates this session, recomputes totals/VAT (replacing the former client-side
/// <c>invSumUpdate</c>) and validates, then signals the browser to re-fetch.
///
/// This is a <b>data holder</b> only — all calculation, validation, persistence
/// and rendering live in <see cref="Fuchs.Services.IInvoiceDraftService"/>
/// (mirroring the <see cref="FdsInvoiceData"/> / <see cref="Fuchs.Services.IInvoiceService"/>
/// split). The editable payload is kept as the exact JSON shape the editor already
/// speaks (<c>admin</c> / <c>new</c> / <c>req</c>), so flushing to the DB can reuse
/// <see cref="Fuchs.Services.IInvoiceService.RegisterInvoiceAsync"/> unchanged.
/// </summary>
public sealed class InvoiceDraftSession
{
/// <summary>Opaque per-editor token; also the SignalR group name for targeted signals.</summary>
public string Token { get; init; } = "";
/// <summary>Owning user account id (drafts are single-user; used for auth + events).</summary>
public string UserAccountId { get; init; } = "";
/// <summary>DB invoice id once the session has been flushed (Zwischenspeichern); empty while cache-only.</summary>
public string InvId { get; set; } = "";
/// <summary>Always true here — sessions only ever hold unfinalised drafts.</summary>
public bool IsDraft { get; set; } = true;
/// <summary>Bumped on every applied mutation; the browser refetches when the signalled version changes.</summary>
public int Version { get; set; }
/// <summary>UTC of the last read/write; drives the idle sliding-TTL and expiry warnings.</summary>
public DateTime LastAccessUtc { get; set; } = DateTime.UtcNow;
/// <summary>Guards against sending more than one expiry warning per idle window.</summary>
public bool ExpiryWarningSent { get; set; }
// ── Editable payload (exact editor JSON shape) ───────────────────────────
/// <summary>Header/admin flags: type, customerid, p13b, setmode, paymentterms…</summary>
public JObject Admin { get; set; } = new();
/// <summary>Recipient/new fields: title/invoicetitle, invoiceaddress, invoiceemail, provisionlocation/-period, CustomValues…</summary>
public JObject New { get; set; } = new();
/// <summary>Service-request blocks; each block is a JObject with an <c>items</c> JArray (the line items).</summary>
public JArray Req { get; set; } = new();
// ── Computed (by the draft service; never trusted from the client) ───────
/// <summary>Server-computed totals/VAT — the values the client used to compute in <c>invSumUpdate</c>.</summary>
public InvoiceDraftSums Sums { get; set; } = new();
/// <summary>Plausibility / consistency results, refreshed on every recompute.</summary>
public List<InvoiceDraftValidationMessage> ValidationMessages { get; } = new();
/// <summary>Automatic change history, appended on every applied patch. Cache-only (never persisted).</summary>
public List<ChangeHistoryEntry> History { get; } = new();
public void Touch() => LastAccessUtc = DateTime.UtcNow;
}
/// <summary>Server-computed invoice totals — the authoritative replacement for the browser's <c>sms</c> object.</summary>
public sealed class InvoiceDraftSums
{
/// <summary>Total net (<c>ttn</c>).</summary>
public decimal TotalNet { get; set; }
/// <summary>Total gross (<c>ttb</c>); equals net when §13b reverse-charge is active.</summary>
public decimal TotalGross { get; set; }
/// <summary>Total VAT (<c>ttvat</c>).</summary>
public decimal TotalVat { get; set; }
/// <summary>Service net (<c>tscn</c>) — the service-refund base.</summary>
public decimal ServiceNet { get; set; }
/// <summary>Service VAT (<c>tscvat</c>).</summary>
public decimal ServiceVat { get; set; }
/// <summary>VAT amount per rate string (e.g. "19" → 123.45), matching the editor's <c>sms.vat</c> map.</summary>
public Dictionary<string, decimal> VatByRate { get; } = new();
/// <summary>Net per block, keyed by block id — feeds the per-block sub-sum row.</summary>
public Dictionary<string, decimal> NetByBlock { get; } = new();
}
/// <summary>A single plausibility/consistency finding for the draft.</summary>
/// <param name="Field">Logical field the message relates to (e.g. "email", "address", "items").</param>
/// <param name="Severity">"error" blocks a clean finalise; "warning"/"info" are advisory.</param>
/// <param name="Message">German, user-readable text.</param>
public readonly record struct InvoiceDraftValidationMessage(string Field, string Severity, string Message);
/// <summary>
/// One automatically-recorded change in the draft's history (shown in the
/// "Änderungshistorie" dialog). Captured on every applied patch; lives only for
/// the cache lifetime of the session and is never persisted to the database.
/// </summary>
public sealed class ChangeHistoryEntry
{
public DateTime TimestampUtc { get; init; } = DateTime.UtcNow;
/// <summary>User account id that made the change.</summary>
public string UserAccountId { get; init; } = "";
/// <summary>The change target/op as sent by the editor (e.g. "item.qty", "email", "p13b").</summary>
public string Target { get; init; } = "";
/// <summary>Optional item/block id the change applied to.</summary>
public string Ref { get; init; } = "";
/// <summary>Previous value, stringified for display (may be empty).</summary>
public string OldValue { get; init; } = "";
/// <summary>New value, stringified for display (may be empty).</summary>
public string NewValue { get; init; } = "";
/// <summary>Version the session reached after applying this change.</summary>
public int Version { get; init; }
}