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

Merged
Stefan merged 12 commits from feature/backend-authoritative-draft-editing into main 2026-07-16 19:46:55 +02:00
26 changed files with 2121 additions and 6 deletions
Showing only changes of commit af445c015e - Show all commits
+113
View File
@@ -0,0 +1,113 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Notifications;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Covers the in-memory draft cache (storage + idle sliding TTL) and the background
/// expiry monitor that warns before eviction and closes the editor on eviction (ADR 0006).
/// </summary>
public class InvoiceDraftCacheTests
{
private static IConfiguration Config(int idle = 30, int warn = 5) =>
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Fuchs:DraftEditing:IdleMinutes"] = idle.ToString(),
["Fuchs:DraftEditing:ExpiryWarnMinutes"] = warn.ToString()
}).Build();
private sealed class FakeNotifier : IDraftNotifier
{
public readonly List<(string token, int version)> Ready = new();
public readonly List<(string token, int secondsLeft)> Expiring = new();
public readonly List<(string token, string reason)> Closed = new();
public Task SignalDraftReadyAsync(string token, int version, CancellationToken ct = default) { Ready.Add((token, version)); return Task.CompletedTask; }
public Task SignalExpiringAsync(string token, int secondsLeft, CancellationToken ct = default) { Expiring.Add((token, secondsLeft)); return Task.CompletedTask; }
public Task SignalClosedAsync(string token, string reason, CancellationToken ct = default) { Closed.Add((token, reason)); return Task.CompletedTask; }
}
// ── Cache storage ─────────────────────────────────────────────────────────
[Fact]
public void SetGet_RoundTrips_AndUnknownTokenIsNull()
{
var cache = new InvoiceDraftCache(Config());
var s = new InvoiceDraftSession { Token = "abc" };
cache.Set(s);
Assert.Same(s, cache.Get("abc"));
Assert.Null(cache.Get("nope"));
}
[Fact]
public void Remove_EvictsSession()
{
var cache = new InvoiceDraftCache(Config());
cache.Set(new InvoiceDraftSession { Token = "x" });
Assert.NotNull(cache.Remove("x"));
Assert.Null(cache.Get("x"));
Assert.Null(cache.Remove("x"));
}
[Fact]
public void Get_ResetsExpiryWarningFlag_SoAFreshWarningIsDue()
{
var cache = new InvoiceDraftCache(Config());
var s = new InvoiceDraftSession { Token = "x", ExpiryWarningSent = true };
cache.Set(s);
cache.Get("x");
Assert.False(s.ExpiryWarningSent);
}
// ── Expiry monitor ────────────────────────────────────────────────────────
[Fact]
public async Task Sweep_NearTtl_WarnsOnceThenEvictsWithReason()
{
var cfg = Config(idle: 30, warn: 5);
var cache = new InvoiceDraftCache(cfg);
var notifier = new FakeNotifier();
var svc = new InvoiceDraftExpiryService(cache, notifier, cfg, NullLogger<InvoiceDraftExpiryService>.Instance);
var s = new InvoiceDraftSession { Token = "a" };
cache.Set(s);
// Idle 26 min → inside the 5-min warning window (30-5=25) but not yet expired.
s.LastAccessUtc = DateTime.UtcNow.AddMinutes(-26);
await svc.SweepAsync(CancellationToken.None);
Assert.Single(notifier.Expiring);
Assert.Empty(notifier.Closed);
Assert.True(s.ExpiryWarningSent);
// Another sweep while still idle must not spam a second warning.
await svc.SweepAsync(CancellationToken.None);
Assert.Single(notifier.Expiring);
// Past the TTL → evicted and the editor is told to close with a reason.
s.LastAccessUtc = DateTime.UtcNow.AddMinutes(-31);
await svc.SweepAsync(CancellationToken.None);
Assert.Single(notifier.Closed);
Assert.Equal(("a", "expired"), notifier.Closed[0]);
Assert.Null(cache.Get("a"));
}
[Fact]
public async Task Sweep_FreshSession_DoesNothing()
{
var cfg = Config(idle: 30, warn: 5);
var cache = new InvoiceDraftCache(cfg);
var notifier = new FakeNotifier();
var svc = new InvoiceDraftExpiryService(cache, notifier, cfg, NullLogger<InvoiceDraftExpiryService>.Instance);
cache.Set(new InvoiceDraftSession { Token = "fresh" });
await svc.SweepAsync(CancellationToken.None);
Assert.Empty(notifier.Expiring);
Assert.Empty(notifier.Closed);
}
}
+158
View File
@@ -0,0 +1,158 @@
using System.Linq;
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Verifies the server-side port of the former client-side invoice math
/// (<c>quantChange</c> + <c>invSumUpdate</c>). Because the truth now lives in the
/// backend (ADR 0006), this logic is finally unit-testable directly.
/// </summary>
public class InvoiceDraftCalculatorTests
{
private static InvoiceDraftSession SessionWith(string reqJson, bool p13b = false)
{
var s = new InvoiceDraftSession { Token = "t" };
s.Admin = new JObject { ["p13b"] = p13b };
s.New = new JObject { ["invoiceemail"] = "kunde@example.de", ["invoiceaddress"] = "Weg 1" };
s.Req = JArray.Parse(reqJson);
return s;
}
// ── RecomputeTotals ──────────────────────────────────────────────────────
[Fact]
public void RecomputeTotals_SumsNetVatServiceAndPerBlock()
{
var s = SessionWith(@"[
{ 'Id':'10','items':[
{'net_val':100,'vat_val':19,'svcnet_val':0,'svcvat_val':0,'vat':'19%','Type':'material'},
{'net_val':50,'vat_val':9.5,'svcnet_val':50,'svcvat_val':9.5,'vat':'19%','Type':'Service'} ] },
{ 'Id':'11','items':[
{'net_val':200,'vat_val':14,'svcnet_val':0,'svcvat_val':0,'vat':'7%','Type':'material'} ] }
]");
InvoiceDraftCalculator.RecomputeTotals(s);
Assert.Equal(350m, s.Sums.TotalNet);
Assert.Equal(42.5m, s.Sums.TotalVat);
Assert.Equal(392.5m, s.Sums.TotalGross);
Assert.Equal(50m, s.Sums.ServiceNet);
Assert.Equal(9.5m, s.Sums.ServiceVat);
Assert.Equal(28.5m, s.Sums.VatByRate["19"]);
Assert.Equal(14m, s.Sums.VatByRate["7"]);
Assert.Equal(150m, s.Sums.NetByBlock["10"]);
Assert.Equal(200m, s.Sums.NetByBlock["11"]);
}
[Fact]
public void RecomputeTotals_ReverseCharge_SuppressesVatAndGrossEqualsNet()
{
var s = SessionWith(@"[{ 'Id':'1','items':[
{'net_val':100,'vat_val':19,'vat':'19%','Type':'material'} ] }]", p13b: true);
InvoiceDraftCalculator.RecomputeTotals(s);
Assert.Equal(100m, s.Sums.TotalNet);
Assert.Equal(100m, s.Sums.TotalGross);
Assert.Equal(0m, s.Sums.TotalVat);
Assert.Empty(s.Sums.VatByRate);
}
[Fact]
public void RecomputeTotals_EmptyDraft_AllZero()
{
var s = SessionWith("[]");
InvoiceDraftCalculator.RecomputeTotals(s);
Assert.Equal(0m, s.Sums.TotalNet);
Assert.Equal(0m, s.Sums.TotalGross);
Assert.Empty(s.Sums.VatByRate);
}
// ── RecomputeItem (quantChange port) ─────────────────────────────────────
[Theory]
[InlineData("Service", true)]
[InlineData("material", false)]
public void RecomputeItem_DerivesLineValuesFromQtyPriceVat(string type, bool isService)
{
var item = new JObject
{
["quantityhours"] = 5, ["net"] = "10", ["vat"] = "19", ["Type"] = type
};
InvoiceDraftCalculator.RecomputeItem(item);
Assert.Equal(50m, item["net_val"]!.Value<decimal>());
Assert.Equal(9.5m, item["vat_val"]!.Value<decimal>());
if (isService)
{
Assert.Equal(50m, item["svcnet_val"]!.Value<decimal>());
Assert.Equal(9.5m, item["svcvat_val"]!.Value<decimal>());
}
else
{
Assert.Null(item["svcnet_val"]);
}
}
[Fact]
public void RecomputeItem_ZeroQuantity_LeavesValuesUntouched()
{
var item = new JObject { ["quantityhours"] = 0, ["net"] = "10", ["vat"] = "19", ["Type"] = "material" };
InvoiceDraftCalculator.RecomputeItem(item);
Assert.Null(item["net_val"]); // guard qty>0 && price>0 not met → no derivation
}
// ── NormalizeRate ────────────────────────────────────────────────────────
[Theory]
[InlineData("19,0%", "19")]
[InlineData("7%", "7")]
[InlineData("19", "19")]
[InlineData("", "")]
[InlineData("0", "")]
[InlineData("7,5", "7.5")]
public void NormalizeRate_CanonicalisesRateStrings(string raw, string expected)
=> Assert.Equal(expected, InvoiceDraftCalculator.NormalizeRate(raw));
// ── Validate ─────────────────────────────────────────────────────────────
[Fact]
public void Validate_ValidDraft_NoErrors()
{
var s = SessionWith(@"[{ 'Id':'1','items':[
{'net_val':100,'vat_val':19,'vat':'19%','Type':'material'} ] }]");
InvoiceDraftCalculator.RecomputeTotals(s);
InvoiceDraftCalculator.Validate(s);
Assert.DoesNotContain(s.ValidationMessages, m => m.Severity == "error");
}
[Theory]
[InlineData("", "warning")] // missing email → advisory
[InlineData("not-an-email", "error")]
public void Validate_EmailProblems_AreFlagged(string email, string severity)
{
var s = SessionWith(@"[{ 'Id':'1','items':[{'net_val':10,'vat':'19%','Type':'material'}] }]");
s.New["invoiceemail"] = email;
InvoiceDraftCalculator.RecomputeTotals(s);
InvoiceDraftCalculator.Validate(s);
Assert.Contains(s.ValidationMessages, m => m.Field == "email" && m.Severity == severity);
}
[Fact]
public void Validate_NoItems_IsError()
{
var s = SessionWith("[]");
InvoiceDraftCalculator.RecomputeTotals(s);
InvoiceDraftCalculator.Validate(s);
Assert.Contains(s.ValidationMessages, m => m.Field == "items" && m.Severity == "error");
}
[Fact]
public void Validate_UnknownVatRate_IsWarning()
{
var s = SessionWith(@"[{ 'Id':'1','items':[{'net_val':10,'vat_val':0.5,'vat':'5%','Type':'material'}] }]");
InvoiceDraftCalculator.RecomputeTotals(s);
InvoiceDraftCalculator.Validate(s);
Assert.Contains(s.ValidationMessages, m => m.Field == "vat" && m.Severity == "warning");
}
}
+155
View File
@@ -0,0 +1,155 @@
using System.Linq;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Services;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using MigraDoc.DocumentObjectModel;
using Newtonsoft.Json.Linq;
using OCORE.security;
using Xunit;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.Tests;
/// <summary>
/// Exercises the draft edit orchestrator's pure paths (open/patch/history/flush)
/// without any database, proving the backend-authoritative model behaves correctly
/// end-to-end at the service seam (ADR 0006).
/// </summary>
public class InvoiceDraftServiceTests
{
/// <summary>Captures the invoice handed to registration and returns it with a fake DB id — no SQL.</summary>
private sealed class FakeInvoiceService : IInvoiceService
{
public FdsInvoiceData? Registered;
public bool? LastChange;
public Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId, string userAccountId, DatabaseSecurity dbSec)
{
Registered = invoice;
LastChange = change;
invoice.InvoiceRegistration = new GenericObjectDictionary(new System.Collections.Generic.Dictionary<string, object> { ["Id"] = "INV42" });
return Task.FromResult(invoice);
}
public Task<FdsInvoiceData> LoadInvoiceAsync(string id, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
public Document GenerateInvoicePdf(FdsInvoiceData i, bool d) => throw new System.NotSupportedException();
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData i, bool d) => throw new System.NotSupportedException();
public Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData i, bool d, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
public Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData i, bool d, fds.IFdsMfr m) => throw new System.NotSupportedException();
}
private static (InvoiceDraftEditService svc, FakeInvoiceService inv, InvoiceDraftCache cache) NewService()
{
var cfg = new ConfigurationBuilder().Build();
var cache = new InvoiceDraftCache(cfg);
var inv = new FakeInvoiceService();
var svc = new InvoiceDraftEditService(cache, inv, intranet: null!, NullLogger<InvoiceDraftEditService>.Instance);
return (svc, inv, cache);
}
private static JObject Payload() => JObject.Parse(@"{
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[{'Id':'1','items':[
{'Id':'900','net_val':100,'vat_val':19,'vat':'19%','Type':'material','net':'10','quantityhours':10} ]}]
}");
[Fact]
public void OpenFromPayload_SeedsSessionAndComputesTotals()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
Assert.False(string.IsNullOrEmpty(s.Token));
Assert.Equal(0, s.Version);
Assert.Equal(100m, s.Sums.TotalNet);
Assert.Equal(119m, s.Sums.TotalGross);
}
[Fact]
public void ApplyPatch_Email_MutatesBumpsVersionAndRecordsHistory()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = JToken.FromObject("neu@x.de") });
Assert.NotNull(s2);
Assert.Equal(1, s2!.Version);
Assert.Equal("neu@x.de", s2.New["invoiceemail"]!.Value<string>());
var h = Assert.Single(s2.History);
Assert.Equal("email", h.Target);
Assert.Equal("a@b.de", h.OldValue);
Assert.Equal("neu@x.de", h.NewValue);
Assert.Equal(1, h.Version);
}
[Fact]
public void ApplyPatch_ItemQty_RecomputesLineAndTotals()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.qty", Ref = "900", Value = JToken.FromObject(5) });
// qty 5 × price 10 = 50 net, 19% → 9.5 VAT.
Assert.Equal(50m, s2!.Sums.TotalNet);
Assert.Equal(9.5m, s2.Sums.VatByRate["19"]);
}
[Fact]
public void ApplyPatch_P13bToggle_FlipsAndSuppressesVat()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "p13b" }); // no value → toggle
Assert.Equal(100m, s2!.Sums.TotalGross); // reverse-charge → gross == net
Assert.Empty(s2.Sums.VatByRate);
}
[Fact]
public void ApplyPatch_UnknownToken_ReturnsNull()
{
var (svc, _, _) = NewService();
Assert.Null(svc.ApplyPatch("ghost", new InvoiceDraftDelta { Target = "email", Value = JToken.FromObject("x@y.de") }));
}
[Fact]
public async Task FlushToDbAsync_RegistersWithMappedTotals_AndSetsInvId()
{
var (svc, inv, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
Assert.NotNull(result);
Assert.Equal("INV42", result!.Id);
Assert.False(inv.LastChange); // new draft (no prior InvId) → create, not update
Assert.Equal("INV42", svc.Get(s.Token)!.InvId);
// The FdsInvoiceData handed to registration carries the session's server-computed totals.
var prms = inv.Registered!.BuildInvoiceParams(change: false, invId: "");
var balance = prms.First(p => p.ParameterName == "@InvoiceBalance");
Assert.Equal("119", System.Convert.ToString(balance.Value, System.Globalization.CultureInfo.InvariantCulture));
var vatRate = prms.First(p => p.ParameterName == "@InvoiceVAT_1");
Assert.Equal("19", vatRate.Value);
}
[Fact]
public async Task DiscardAsync_NeverSaved_ReturnsSessionUnchanged()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var back = await svc.DiscardAsync(s.Token, "user1", null!);
Assert.Same(s, back); // no InvId → nothing to reload from the DB
}
[Fact]
public void GetHistory_UnknownToken_IsEmpty()
{
var (svc, _, _) = NewService();
Assert.Empty(svc.GetHistory("ghost"));
}
}
@@ -0,0 +1,146 @@
using Fuchs.intranet;
using Fuchs.Services;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using static OCORE.web.mvc_helper_async;
namespace Fuchs.Controllers;
// Partial class: live, backend-authoritative invoice draft editing (ADR 0006).
// The browser posts single edits here; the server mutates the in-memory session
// (the source of truth), recomputes/validates, and pings the editing browser over
// SignalR (draftReady) to re-fetch. Commands are ordinary POSTs — the hub carries
// only signals (ADR 0007).
public partial class IntranetController
{
/// <summary>Standard 410 when a session token is unknown/expired — the client re-opens the draft.</summary>
private IActionResult DraftGone() => StatusCode(410, new { error = "expired" });
// POST inv/dopen — { id? | payload? } → { token, version }
private async Task<IActionResult> HandleDraftOpen(string fn, string id, string code)
{
InvoiceDraftSession session;
if (HasForm("id") && !string.IsNullOrEmpty(Form("id")))
{
_logger.LogInformation("Draft dopen: from DB draft {InvId} user={User}", Form("id"), UserAccountID);
session = await _invoiceDrafts.OpenFromDraftAsync(Form("id"), UserAccountID, DbSec);
}
else if (HasForm("payload"))
{
_logger.LogInformation("Draft dopen: from payload user={User}", UserAccountID);
JObject payload;
try { payload = JObject.Parse(Form("payload")); }
catch (JsonException ex)
{
_logger.LogWarning(ex, "Draft dopen: invalid payload JSON user={User}", UserAccountID);
return BadRequest400();
}
session = _invoiceDrafts.OpenFromPayload(payload, UserAccountID);
}
else
{
_logger.LogWarning("Draft dopen: neither 'id' nor 'payload' supplied user={User}", UserAccountID);
return BadRequest400();
}
// The browser holds the token from this response and fetches dstate directly; there is
// no server 'draftReady' on open (it would race the client's group-join). Signals drive
// only subsequent server-side changes.
return await JSONAsync(new { token = session.Token, version = session.Version });
}
// POST inv/dstate — { token } → full view state
private async Task<IActionResult> HandleDraftState(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
var session = _invoiceDrafts.Get(Form("token"));
if (session == null) return DraftGone();
return await JSONAsync(_invoiceDrafts.BuildState(session));
}
// POST inv/dpatch — { token, delta } → { ok, version }; signals draftReady
private async Task<IActionResult> HandleDraftPatch(string fn, string id, string code)
{
if (!HasForm("token", "delta")) return BadRequest400();
InvoiceDraftDelta? delta;
try { delta = JsonConvert.DeserializeObject<InvoiceDraftDelta>(Form("delta")); }
catch (JsonException ex)
{
_logger.LogWarning(ex, "Draft dpatch: invalid delta JSON user={User}", UserAccountID);
return BadRequest400();
}
if (delta == null || string.IsNullOrEmpty(delta.Target)) return BadRequest400();
var session = _invoiceDrafts.ApplyPatch(Form("token"), delta);
if (session == null) return DraftGone();
await _draftNotifier.SignalDraftReadyAsync(session.Token, session.Version);
return await JSONAsync(new { ok = true, version = session.Version });
}
// POST inv/dpreview — { token } → { img[], total } (rendered straight from the cache)
private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
var doc = _invoiceDrafts.RenderPreview(Form("token"));
if (doc == null) return DraftGone();
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
}
// POST inv/dsave — { token } → { ok, invid }; flush cache→DB + business event + draftReady
private async Task<IActionResult> HandleDraftSave(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
string token = Form("token");
var before = _invoiceDrafts.Get(token);
if (before == null) return DraftGone();
bool existed = !string.IsNullOrEmpty(before.InvId);
var fdInv = await _invoiceDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
if (fdInv == null) return DraftGone();
if (string.IsNullOrEmpty(fdInv.Id))
return await InvoiceIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
await _events.InvoiceDraftRegisteredAsync(fdInv, existed, UserAccountID);
var after = _invoiceDrafts.Get(token);
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
return await JSONAsync(new { ok = true, invid = fdInv.Id });
}
// POST inv/dhistory — { token } → { history[] }
private async Task<IActionResult> HandleDraftHistory(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
if (_invoiceDrafts.Get(Form("token")) == null) return DraftGone();
var history = _invoiceDrafts.GetHistory(Form("token"))
.Select(h => new
{
timestamp = h.TimestampUtc,
target = h.Target,
@ref = h.Ref,
oldValue = h.OldValue,
newValue = h.NewValue,
version = h.Version
});
return await JSONAsync(new { history });
}
// POST inv/ddiscard — { token } → { ok, version }; reload from DB + draftReady
private async Task<IActionResult> HandleDraftDiscard(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
var session = await _invoiceDrafts.DiscardAsync(Form("token"), UserAccountID, DbSec);
if (session == null) return DraftGone();
await _draftNotifier.SignalDraftReadyAsync(session.Token, session.Version);
return await JSONAsync(new { ok = true, version = session.Version });
}
// POST inv/dclose — { token } → { ok }
private async Task<IActionResult> HandleDraftClose(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
bool ok = _invoiceDrafts.Close(Form("token"));
_logger.LogDebug("Draft dclose token={Token} removed={Removed} user={User}", Form("token"), ok, UserAccountID);
return await JSONAsync(new { ok });
}
}
@@ -158,6 +158,16 @@ public partial class IntranetController
fds.FdsMfr.UpdateNeed.Reset, new[] { relId });
return await JSONAsync(new { ok = true });
// ── Live backend-authoritative draft editing (ADR 0006) ───────────
case "dopen": return await HandleDraftOpen(fn, id, code);
case "dstate": return await HandleDraftState(fn, id, code);
case "dpatch": return await HandleDraftPatch(fn, id, code);
case "dpreview": return await HandleDraftPreview(fn, id, code);
case "dsave": return await HandleDraftSave(fn, id, code);
case "dhistory": return await HandleDraftHistory(fn, id, code);
case "ddiscard": return await HandleDraftDiscard(fn, id, code);
case "dclose": return await HandleDraftClose(fn, id, code);
default:
_logger.LogWarning("Do_Process_Invoices: unhandled action id={Id}, user={User}", id, UserAccountID);
return await JSONAsync(new { ok = true });
+7 -1
View File
@@ -35,6 +35,8 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
private readonly IInvoiceService _invoices;
private readonly IReminderService _reminders;
private readonly IEventService _events;
private readonly IInvoiceDraftService _invoiceDrafts;
private readonly IDraftNotifier _draftNotifier;
private readonly List<string> _allowedNonAuth = new() { "spwc", "spw" };
private readonly List<string> _allowedGet = new()
{
@@ -62,7 +64,9 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
IReportService reports,
IInvoiceService invoices,
IReminderService reminders,
IEventService events)
IEventService events,
IInvoiceDraftService invoiceDrafts,
IDraftNotifier draftNotifier)
{
_intranet = intranet;
_mfr = mfr;
@@ -76,6 +80,8 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
_invoices = invoices;
_reminders = reminders;
_events = events;
_invoiceDrafts = invoiceDrafts;
_draftNotifier = draftNotifier;
}
/// <summary>Merged query-string + form parameters (form wins) for report processing.</summary>
+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.
+45
View File
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace Fuchs.Notifications;
/// <summary>
/// <see cref="IDraftNotifier"/> over the <see cref="DraftPreviewHub"/>. Sends to the
/// SignalR group named after the draft token so only the editing browser is notified.
/// Like <see cref="EventService.PublishAsync"/>, 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).
/// </summary>
public sealed class DraftNotifier : IDraftNotifier
{
private readonly IHubContext<DraftPreviewHub> _hub;
private readonly ILogger<DraftNotifier> _logger;
public DraftNotifier(IHubContext<DraftPreviewHub> hub, ILogger<DraftNotifier> logger)
{
_hub = hub;
_logger = logger;
}
public Task SignalDraftReadyAsync(string token, int version, CancellationToken cancellationToken = default) =>
SendAsync(token, "draftReady", new { token, version }, cancellationToken);
public Task SignalExpiringAsync(string token, int secondsLeft, CancellationToken cancellationToken = default) =>
SendAsync(token, "draftExpiring", new { token, secondsLeft }, cancellationToken);
public Task SignalClosedAsync(string token, string reason, CancellationToken cancellationToken = default) =>
SendAsync(token, "draftClosed", new { token, reason }, cancellationToken);
private async Task SendAsync(string token, string method, object payload, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(token)) return;
try
{
await _hub.Clients.Group(token).SendAsync(method, payload, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Draft signal {Method} failed for token {Token}", method, token);
}
}
}
+31
View File
@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace Fuchs.Notifications;
/// <summary>
/// SignalR hub for live invoice/reminder draft editing (see ADR 0006 / 0007).
///
/// Deliberately separate from <see cref="NotificationHub"/>: that hub broadcasts
/// business toasts to <b>all</b> logged-in sessions (ADR 0002), whereas draft
/// signals must be <b>targeted</b> at the one browser editing a given draft.
/// Targeting is done with a SignalR group named after the draft's session token —
/// each editor calls <see cref="JoinDraft"/> after opening a draft.
///
/// The hub carries no commands: edits, saves and discards travel as ordinary POSTs
/// (see ADR 0006). The hub only manages group membership and delivers the server's
/// <c>draftReady</c> / <c>draftExpiring</c> / <c>draftClosed</c> signals.
/// </summary>
[Authorize]
public sealed class DraftPreviewHub : Hub
{
/// <summary>Subscribes this connection to a draft's signal group.</summary>
public Task JoinDraft(string token) =>
string.IsNullOrEmpty(token) ? Task.CompletedTask
: Groups.AddToGroupAsync(Context.ConnectionId, token);
/// <summary>Unsubscribes this connection from a draft's signal group.</summary>
public Task LeaveDraft(string token) =>
string.IsNullOrEmpty(token) ? Task.CompletedTask
: Groups.RemoveFromGroupAsync(Context.ConnectionId, token);
}
+20
View File
@@ -0,0 +1,20 @@
namespace Fuchs.Notifications;
/// <summary>
/// Sends <b>system-internal</b> draft-editing signals to the one browser editing a
/// given draft, over the <see cref="DraftPreviewHub"/> group keyed by session token
/// (see ADR 0006 / 0007). These are coordination pings, not business notifications:
/// user-facing success/failure messages (e.g. "Zwischenstand gespeichert") still go
/// through <see cref="IEventService"/> / <see cref="NotificationHub"/>.
/// </summary>
public interface IDraftNotifier
{
/// <summary>The cached draft reached a new <paramref name="version"/> — the client should re-fetch its state.</summary>
Task SignalDraftReadyAsync(string token, int version, CancellationToken cancellationToken = default);
/// <summary>The draft is about to expire in <paramref name="secondsLeft"/>s unless saved — warn the user.</summary>
Task SignalExpiringAsync(string token, int secondsLeft, CancellationToken cancellationToken = default);
/// <summary>The draft session was removed (evicted/expired/discarded) — the client must close the editor and show why.</summary>
Task SignalClosedAsync(string token, string reason, CancellationToken cancellationToken = default);
}
+9
View File
@@ -110,6 +110,14 @@ public class Program
builder.Services.AddScoped<IReminderService, ReminderService>();
builder.Services.AddScoped<IEventService, EventService>();
// Live, backend-authoritative invoice draft editing (ADR 0006): an in-memory
// draft cache (singleton), the scoped edit orchestrator, a targeted SignalR
// notifier over the dedicated DraftPreviewHub, and the idle-expiry monitor.
builder.Services.AddSingleton<IInvoiceDraftCache, InvoiceDraftCache>();
builder.Services.AddSingleton<IDraftNotifier, DraftNotifier>();
builder.Services.AddScoped<IInvoiceDraftService, InvoiceDraftEditService>();
builder.Services.AddHostedService<InvoiceDraftExpiryService>();
// Secondary archive: invoice/reminder PDFs additionally stored in Azure Blob Storage.
// Disabled by default (Fuchs:AzureStorage:Enabled) — see AzureBlobStorageService.
builder.Services.Configure<AzureBlobStorageSettings>(builder.Configuration.GetSection("Fuchs:AzureStorage"));
@@ -184,6 +192,7 @@ public class Program
app.UseAuthentication();
app.UseAuthorization();
app.MapHub<NotificationHub>("/notifications");
app.MapHub<DraftPreviewHub>("/draftpreview");
// Intranet routes (root-level — this IS the website)
app.MapControllerRoute(
+26
View File
@@ -0,0 +1,26 @@
using Fuchs.intranet;
namespace Fuchs.Services;
/// <summary>
/// In-memory store of live invoice draft editing sessions (see ADR 0006).
/// Singleton, single-instance only — scale-out would need a distributed cache /
/// sticky sessions (documented limitation). Keyed by the session token.
/// </summary>
public interface IInvoiceDraftCache
{
/// <summary>Stores (or replaces) a session under its token.</summary>
void Set(InvoiceDraftSession session);
/// <summary>Returns the session for the token, or null if absent/evicted. Touches <c>LastAccessUtc</c> on hit.</summary>
InvoiceDraftSession? Get(string token);
/// <summary>Removes the session (explicit close/discard/finalise). Returns the removed session, if any.</summary>
InvoiceDraftSession? Remove(string token);
/// <summary>Snapshot of all live sessions — used by the expiry monitor. Does not touch access time.</summary>
IReadOnlyList<InvoiceDraftSession> Snapshot();
/// <summary>The configured idle time-to-live before a session is eligible for eviction.</summary>
TimeSpan IdleTtl { get; }
}
+82
View File
@@ -0,0 +1,82 @@
using Fuchs.intranet;
using MigraDoc.DocumentObjectModel;
using Newtonsoft.Json.Linq;
using OCORE.security;
namespace Fuchs.Services;
/// <summary>
/// Orchestrates a live, backend-authoritative invoice draft editing session
/// (ADR 0006). Owns the lifecycle around an <see cref="InvoiceDraftSession"/>:
/// open (seed the cache), apply single edits, build the view state, render a PDF
/// preview from the cache, flush to the DB ("Zwischenspeichern"), discard (reload
/// from the DB) and expose the change history. All totals/VAT are computed by
/// <see cref="InvoiceDraftCalculator"/> — the browser never calculates.
/// </summary>
public interface IInvoiceDraftService
{
/// <summary>
/// Seeds a new cache session for a brand-new draft from the editor's initially
/// assembled payload (<c>admin</c> / <c>new</c> / <c>req</c> blocks). Computes
/// totals + validation and returns the session (with its fresh token/version).
/// </summary>
InvoiceDraftSession OpenFromPayload(JObject payload, string userAccountId);
/// <summary>
/// Seeds a cache session by loading an existing DB draft (<c>fds__getInvoice</c>)
/// and reshaping it into the editor's block/item structure. Computes + caches.
/// </summary>
Task<InvoiceDraftSession> OpenFromDraftAsync(string invId, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Returns the cached session for the token (touching its TTL), or null if absent/expired.</summary>
InvoiceDraftSession? Get(string token);
/// <summary>
/// Applies one editor change to the cached session: mutates the payload,
/// re-derives affected item math + totals, re-validates, appends a history entry
/// and bumps the version. Returns the mutated session, or null if the token is unknown.
/// </summary>
InvoiceDraftSession? ApplyPatch(string token, InvoiceDraftDelta delta);
/// <summary>Builds the JSON view-state DTO the frontend renders (payload + server sums + validation + version).</summary>
object BuildState(InvoiceDraftSession session);
/// <summary>The draft's change history for the "Änderungshistorie" dialog (empty if the token is unknown).</summary>
IReadOnlyList<ChangeHistoryEntry> GetHistory(string token);
/// <summary>
/// Persists the cached session to the DB via the existing invoice registration
/// path ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success.
/// Returns the registered invoice data (for the success event), or null if the token is unknown.
/// </summary>
Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
Document? RenderPreview(string token);
/// <summary>
/// Discards the session's in-memory changes by reloading it from the DB draft
/// (requires a prior flush / an existing <c>InvId</c>). Bumps the version so the
/// client refetches. Returns the reloaded session, or null if the token is unknown.
/// </summary>
Task<InvoiceDraftSession?> DiscardAsync(string token, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Removes the session from the cache (explicit close/finalise). Returns true if one was present.</summary>
bool Close(string token);
}
/// <summary>
/// A single editor change posted to <c>inv/dpatch</c>. <see cref="Target"/> names the
/// field/operation (e.g. "email", "p13b", "item.qty"); <see cref="Ref"/> is the item or
/// block id it applies to (when relevant); <see cref="Value"/> is the new value.
/// </summary>
public sealed class InvoiceDraftDelta
{
public string Target { get; set; } = "";
public string Ref { get; set; } = "";
public JToken? Value { get; set; }
/// <summary>The new value as a string (empty when null), for history and simple field assignments.</summary>
public string ValueString =>
Value == null || Value.Type == JTokenType.Null ? "" : Value.Type == JTokenType.String ? Value.Value<string>() ?? "" : Value.ToString();
}
+60
View File
@@ -0,0 +1,60 @@
using System.Collections.Concurrent;
using Fuchs.intranet;
using Microsoft.Extensions.Configuration;
namespace Fuchs.Services;
/// <summary>
/// Single-instance, in-memory implementation of <see cref="IInvoiceDraftCache"/>
/// backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/> keyed by session
/// token. A plain dictionary (rather than <c>IMemoryCache</c>) is used on purpose:
/// the <see cref="InvoiceDraftExpiryService"/> needs to enumerate sessions and warn
/// the user <b>before</b> eviction, which opaque cache-entry expiry does not allow.
///
/// Idle TTL and the pre-expiry warning lead time are configurable under
/// <c>Fuchs:DraftEditing</c> (<c>IdleMinutes</c> / <c>ExpiryWarnMinutes</c>).
/// </summary>
public sealed class InvoiceDraftCache : IInvoiceDraftCache
{
private readonly ConcurrentDictionary<string, InvoiceDraftSession> _sessions = new(StringComparer.Ordinal);
public TimeSpan IdleTtl { get; }
/// <summary>How long before the idle TTL a warning is emitted to the user.</summary>
public TimeSpan ExpiryWarnLead { get; }
public InvoiceDraftCache(IConfiguration configuration)
{
int idleMinutes = configuration.GetValue("Fuchs:DraftEditing:IdleMinutes", 30);
int warnMinutes = configuration.GetValue("Fuchs:DraftEditing:ExpiryWarnMinutes", 5);
IdleTtl = TimeSpan.FromMinutes(Math.Max(1, idleMinutes));
ExpiryWarnLead = TimeSpan.FromMinutes(Math.Clamp(warnMinutes, 1, Math.Max(1, idleMinutes - 1)));
}
public void Set(InvoiceDraftSession session)
{
if (string.IsNullOrEmpty(session.Token)) throw new ArgumentException("Session has no token.", nameof(session));
session.Touch();
_sessions[session.Token] = session;
}
public InvoiceDraftSession? Get(string token)
{
if (string.IsNullOrEmpty(token)) return null;
if (_sessions.TryGetValue(token, out var s))
{
s.Touch();
// A touch resets the idle window, so a fresh warning is due next time it lapses.
s.ExpiryWarningSent = false;
return s;
}
return null;
}
public InvoiceDraftSession? Remove(string token)
{
if (string.IsNullOrEmpty(token)) return null;
return _sessions.TryRemove(token, out var s) ? s : null;
}
public IReadOnlyList<InvoiceDraftSession> Snapshot() => _sessions.Values.ToList();
}
+504
View File
@@ -0,0 +1,504 @@
using System.Globalization;
using System.Web;
using Fuchs.intranet;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
using Newtonsoft.Json.Linq;
using OCORE.security;
using OCORE.SQL;
using static OCORE.commons;
using static OCORE.OCORE_dictionaries;
using static OCORE.SQL.sql;
namespace Fuchs.Services;
/// <summary>
/// Backend-authoritative invoice draft editing (ADR 0006). Holds the truth in an
/// <see cref="InvoiceDraftSession"/> (via <see cref="IInvoiceDraftCache"/>), applies
/// single edits, computes totals with <see cref="InvoiceDraftCalculator"/>, renders
/// previews and flushes to the DB by reusing the existing <see cref="IInvoiceService"/>
/// registration path — no new persistence. Deliberately I/O-thin so the calculation
/// remains unit-testable.
/// </summary>
public sealed class InvoiceDraftEditService : IInvoiceDraftService
{
private readonly IInvoiceDraftCache _cache;
private readonly IInvoiceService _invoices;
private readonly Fuchs_intranet _intranet;
private readonly ILogger<InvoiceDraftEditService> _logger;
public InvoiceDraftEditService(IInvoiceDraftCache cache, IInvoiceService invoices,
Fuchs_intranet intranet, ILogger<InvoiceDraftEditService> logger)
{
_cache = cache;
_invoices = invoices;
_intranet = intranet;
_logger = logger;
}
private string Conn => _intranet.Intranet__SQLConnectionString;
// ── Open ─────────────────────────────────────────────────────────────────
public InvoiceDraftSession OpenFromPayload(JObject payload, string userAccountId)
{
var session = new InvoiceDraftSession
{
Token = NewToken(),
UserAccountId = userAccountId,
InvId = payload["invid"]?.Value<string>() ?? payload["id"]?.Value<string>() ?? ""
};
session.Admin = payload["admin"] as JObject ?? new JObject();
session.New = payload["new"] as JObject ?? new JObject();
session.Req = payload["req"] as JArray ?? new JArray();
Refresh(session);
_cache.Set(session);
_logger.LogInformation("Draft session {Token} opened from payload (invId={InvId}, user={User})",
session.Token, session.InvId, userAccountId);
return session;
}
public async Task<InvoiceDraftSession> OpenFromDraftAsync(string invId, string userAccountId, DatabaseSecurity dbSec)
{
var session = new InvoiceDraftSession { Token = NewToken(), UserAccountId = userAccountId, InvId = invId };
await LoadDraftIntoAsync(session, invId, userAccountId, dbSec);
Refresh(session);
_cache.Set(session);
_logger.LogInformation("Draft session {Token} opened from DB draft {InvId} (user={User})",
session.Token, invId, userAccountId);
return session;
}
public InvoiceDraftSession? Get(string token) => _cache.Get(token);
// ── Patch ──────────────────────────────────────────────────────────────────
public InvoiceDraftSession? ApplyPatch(string token, InvoiceDraftDelta delta)
{
var session = _cache.Get(token);
if (session == null) return null;
string oldValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue);
if (!mutated)
{
_logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
return session;
}
Refresh(session);
session.Version++;
session.History.Add(new ChangeHistoryEntry
{
UserAccountId = session.UserAccountId,
Target = delta.Target,
Ref = delta.Ref,
OldValue = oldValue,
NewValue = delta.ValueString,
Version = session.Version
});
_cache.Set(session);
return session;
}
/// <summary>Applies one delta to the payload; returns whether anything changed and captures the prior value.</summary>
private static bool ApplyDelta(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue)
{
switch (d.Target)
{
case "email": return SetNew(s, "invoiceemail", d, ref oldValue);
case "address": return SetNew(s, "invoiceaddress", d, ref oldValue);
case "title": return SetNew(s, "invoicetitle", d, ref oldValue);
case "provisionperiod": return SetNew(s, "provisionperiod", d, ref oldValue);
case "provisionlocation":
oldValue = Str(s.New["provisionlocation"]);
s.New["provisionlocation"] = d.ValueString;
s.New["loc"] = d.ValueString; // editor mirrors both
return true;
case "contact": return SetContact(s, d, ref oldValue);
case "setmode": return SetAdmin(s, "setmode", d, ref oldValue);
case "p13b":
oldValue = Str(s.Admin["p13b"]);
bool next = d.Value != null && d.Value.Type != JTokenType.Null
? AsBool(d.Value)
: !AsBool(s.Admin["p13b"]); // toggle when no explicit value
s.Admin["p13b"] = next;
return true;
case "item.qty": return SetItem(s, d, "quantityhours", recompute: true, ref oldValue);
case "item.price": return SetItem(s, d, "net", recompute: true, ref oldValue);
case "item.note": return SetItem(s, d, "Note", recompute: false, ref oldValue);
case "item.remove": return RemoveItem(s, d, ref oldValue);
case "block.combine":
case "item.combine": return SetBlockFlag(s, d, "onesum", ref oldValue);
case "block.remove": return RemoveBlock(s, d, ref oldValue);
default: return false;
}
}
private static bool SetNew(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue)
{
oldValue = Str(s.New[key]);
s.New[key] = d.ValueString;
return true;
}
private static bool SetAdmin(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue)
{
oldValue = Str(s.Admin[key]);
s.Admin[key] = d.ValueString;
return true;
}
private static bool SetContact(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue)
{
oldValue = Str(s.New["CustomValues"]);
JObject cvo = TryParseObject(oldValue);
if (d.Value is JObject vo)
{
cvo["contactName"] = vo["name"] ?? vo["contactName"] ?? "";
cvo["contactEmail"] = vo["email"] ?? vo["contactEmail"] ?? "";
}
s.New["CustomValues"] = cvo.ToString(Newtonsoft.Json.Formatting.None);
return true;
}
private static bool SetItem(InvoiceDraftSession s, InvoiceDraftDelta d, string key, bool recompute, ref string oldValue)
{
var item = FindItem(s, d.Ref);
if (item == null) return false;
oldValue = Str(item[key]);
item[key] = d.Value ?? JValue.CreateString(d.ValueString);
if (recompute) InvoiceDraftCalculator.RecomputeItem(item);
return true;
}
private static bool RemoveItem(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue)
{
var item = FindItem(s, d.Ref);
if (item == null) return false;
oldValue = Str(item["NameOrNumber"]).ne(Str(item["htmltext"]));
item.Remove();
return true;
}
private static bool SetBlockFlag(InvoiceDraftSession s, InvoiceDraftDelta d, string key, ref string oldValue)
{
var block = FindBlock(s, d.Ref);
if (block == null) return false;
oldValue = Str(block[key]);
block[key] = d.Value != null && d.Value.Type != JTokenType.Null ? AsBool(d.Value) : !AsBool(block[key]);
return true;
}
private static bool RemoveBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue)
{
var block = FindBlock(s, d.Ref);
if (block == null) return false;
oldValue = Str(block["text"]);
block.Remove();
return true;
}
// ── View state / history ────────────────────────────────────────────────
public object BuildState(InvoiceDraftSession session)
{
session.Touch();
return new
{
token = session.Token,
version = session.Version,
invid = session.InvId,
isDraft = session.IsDraft,
admin = session.Admin,
@new = session.New,
req = session.Req,
sums = new
{
total_net = session.Sums.TotalNet,
total_gross = session.Sums.TotalGross,
total_vat = session.Sums.TotalVat,
service_net = session.Sums.ServiceNet,
service_vat = session.Sums.ServiceVat,
vat = session.Sums.VatByRate,
block_net = session.Sums.NetByBlock
},
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }),
historyCount = session.History.Count
};
}
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
// ── Flush / preview / discard ─────────────────────────────────────────────
public async Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
{
var session = _cache.Get(token);
if (session == null) return null;
var fds = BuildFdsData(session);
bool change = !string.IsNullOrEmpty(session.InvId);
var reg = await _invoices.RegisterInvoiceAsync(fds, change, session.InvId, userAccountId, dbSec);
if (!string.IsNullOrEmpty(reg.Id))
{
session.InvId = reg.Id;
_cache.Set(session);
_logger.LogInformation("Draft {Token} flushed to DB invoice {InvId} (change={Change}, user={User})",
token, reg.Id, change, userAccountId);
}
return reg;
}
public Document? RenderPreview(string token)
{
var session = _cache.Get(token);
if (session == null) return null;
var fds = BuildFdsData(session);
fds.InvoiceRegistration = SynthesizeRegistration(session);
fds.IsDraft = true;
return _invoices.GenerateInvoicePdf(fds, draft: true);
}
public async Task<InvoiceDraftSession?> DiscardAsync(string token, string userAccountId, DatabaseSecurity dbSec)
{
var session = _cache.Get(token);
if (session == null) return null;
if (string.IsNullOrEmpty(session.InvId))
{
_logger.LogInformation("Draft {Token} discard requested but never saved — nothing to reload (user={User})", token, userAccountId);
return session;
}
await LoadDraftIntoAsync(session, session.InvId, userAccountId, dbSec);
Refresh(session);
session.Version++;
session.History.Add(new ChangeHistoryEntry
{
UserAccountId = userAccountId,
Target = "discard",
NewValue = "Änderungen verworfen",
Version = session.Version
});
_cache.Set(session);
_logger.LogInformation("Draft {Token} discarded, reloaded from DB invoice {InvId} (user={User})", token, session.InvId, userAccountId);
return session;
}
public bool Close(string token) => _cache.Remove(token) != null;
// ── Internals ──────────────────────────────────────────────────────────────
private static void Refresh(InvoiceDraftSession session)
{
InvoiceDraftCalculator.RecomputeTotals(session);
InvoiceDraftCalculator.Validate(session);
}
private static string NewToken() => Guid.NewGuid().ToString("N");
private static JObject? FindBlock(InvoiceDraftSession s, string blockId)
{
foreach (var b in s.Req)
if (b is JObject bo && Str(bo["Id"]) == blockId) return bo;
return null;
}
private static JObject? FindItem(InvoiceDraftSession s, string itemId)
{
foreach (var b in s.Req)
if (b is JObject bo && bo["items"] is JArray items)
foreach (var it in items)
if (it is JObject io && Str(io["Id"]) == itemId) return io;
return null;
}
/// <summary>Builds the <see cref="FdsInvoiceData"/> from the session — the server-side port of <c>invcPayload</c>.</summary>
private FdsInvoiceData BuildFdsData(InvoiceDraftSession session)
{
var adm = (JObject)session.Admin.DeepClone();
var nw = (JObject)session.New.DeepClone();
nw["total_net"] = session.Sums.TotalNet;
nw["total_gross"] = session.Sums.TotalGross;
nw["title"] = nw["invoicetitle"] ?? nw["title"] ?? "";
nw["provisionlocation"] = nw["loc"] ?? nw["provisionlocation"] ?? "";
nw["paymentterm"] = adm["paymentterms"] ?? nw["paymentterm"] ?? "";
adm["customerid"] = adm["customerid"] ?? adm["CustomerId"];
var vat = new JObject();
foreach (var kv in session.Sums.VatByRate) vat[kv.Key] = kv.Value;
var sms = new JObject
{
["ttn"] = session.Sums.TotalNet,
["ttb"] = session.Sums.TotalGross,
["ttvat"] = session.Sums.TotalVat,
["tscn"] = session.Sums.ServiceNet,
["tscvat"] = session.Sums.ServiceVat,
["vat"] = vat
};
var jobj = new JObject
{
["admin"] = adm,
["new"] = nw,
["sms"] = sms,
["req"] = session.Req.DeepClone()
};
return new FdsInvoiceData(jobj);
}
/// <summary>
/// Synthesises the <c>InvoiceRegistration</c> dictionary a draft PDF render needs,
/// straight from the cached session — so a preview requires no DB round-trip and no
/// client upload. Mirrors the columns <c>fds__getInvoice</c> would return for a draft.
/// </summary>
private GenericObjectDictionary SynthesizeRegistration(InvoiceDraftSession session)
{
string title = Str(session.New["invoicetitle"]).ne(Str(session.New["title"]));
string loc = Str(session.New["provisionlocation"]).ne(Str(session.New["loc"]));
var d = new Dictionary<string, object>
{
["Id"] = session.InvId,
["InvoiceType"] = Str(session.Admin["type"]).ne("R"),
["InvoiceId"] = "",
["InvoiceTitle"] = title,
["SendToAddress"] = Str(session.New["invoiceaddress"]),
["SendToEmail"] = Str(session.New["invoiceemail"]),
["ProvisionLocation"] = loc,
["ProvisionPeriod"] = Str(session.New["provisionperiod"]),
["PaymentTerm"] = Str(session.Admin["paymentterms"]).ne(Str(session.New["paymentterm"])),
["InvoiceBalance"] = session.Sums.TotalGross,
["InvoiceBalance_net"] = session.Sums.TotalNet,
["CustomValues"] = Str(session.New["CustomValues"]),
["InvoiceOptions"] = BuildInvoiceOptions(session),
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
};
int idx = 0;
foreach (var kv in session.Sums.VatByRate)
{
idx++;
if (idx > 2) break;
d[$"InvoiceVAT_{idx}"] = kv.Key;
d[$"InvoiceVAT_net{idx}"] = kv.Value;
}
return new GenericObjectDictionary(d);
}
/// <summary>Builds the InvoiceOptions CSV (§13b + setmode) from the session admin flags — matches <see cref="FdsInvoiceData.BuildInvoiceOptions"/>.</summary>
private static string BuildInvoiceOptions(InvoiceDraftSession session)
{
var tokens = new List<string>();
if (AsBool(session.Admin["p13b"])) tokens.Add("§13b");
string setmode = Str(session.Admin["setmode"]).Trim().ToLowerInvariant();
if (setmode is "itemprices" or "setonly") tokens.Add("setmode:" + setmode);
return string.Join(",", tokens);
}
/// <summary>Loads the DB draft (<c>fds__getInvoice</c>) into the session's payload — the port of HandleInvoiceGet + BuildInvoiceRequestList.</summary>
private async Task LoadDraftIntoAsync(InvoiceDraftSession session, string invId, string userAccountId, DatabaseSecurity dbSec)
{
var pl = new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId), SQL_VarChar("@Id", invId) };
var dset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getInvoice] @Id, @authuser;",
Conn, pl, tablenames: new[] { "admin", "inv", "req", "itm" },
Security: dbSec, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(dset.Exception))
_logger.LogError("LoadDraftIntoAsync sql exception for {InvId}: {Ex}", invId, dset.Exception);
var adminDic = dset.Table("admin").FirstRow.toObjectDictionary();
var invDic = dset.Table("inv").FirstRow.toObjectDictionary();
string invoiceOptions = invDic.nz("InvoiceOptions", "");
bool p13b = invoiceOptions.Split(',').Contains("§13b");
string setmode = invoiceOptions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault(t => t.StartsWith("setmode:", StringComparison.OrdinalIgnoreCase))?["setmode:".Length..] ?? "";
var admin = JObject.FromObject(adminDic);
admin["type"] = admin["type"] ?? JValue.CreateString(invDic.nz("InvoiceType").Substr(0, 1));
admin["p13b"] = p13b;
if (!string.IsNullOrEmpty(setmode)) admin["setmode"] = setmode;
var nw = new JObject
{
["invoicetitle"] = invDic.nz("InvoiceTitle"),
["title"] = invDic.nz("InvoiceTitle"),
["invoiceaddress"] = invDic.nz("SendToAddress"),
["invoiceemail"] = invDic.nz("SendToEmail"),
["provisionlocation"] = invDic.nz("ProvisionLocation"),
["loc"] = invDic.nz("ProvisionLocation"),
["provisionperiod"] = invDic.nz("ProvisionPeriod"),
["CustomValues"] = invDic.nz("CustomValues"),
["paymentterm"] = invDic.nz("PaymentTerm")
};
session.Admin = admin;
session.New = nw;
session.Req = BuildDraftBlocks(dset);
session.IsDraft = invDic.getItem("IsFinal", false) is not true;
}
/// <summary>Reshapes the <c>fds__getInvoice</c> req/itm tables into the editor's block/item JSON (port of BuildInvoiceRequestList).</summary>
private static JArray BuildDraftBlocks(SQLDataSet dset)
{
var blocks = new JArray();
foreach (System.Data.DataRow rq in dset.Tables("req").Select("",
dset.Tables("req").Columns.Contains("order") ? "order" : ""))
{
var rdic = rq.toObjectDictionary();
var block = new JObject
{
["Id"] = rdic["mfr__servicerequest"]?.ToString() ?? "",
["InvRqId"] = rdic["Id"]?.ToString() ?? "",
["text"] = HttpUtility.HtmlDecode(rdic["title"]?.ToString() ?? "")
};
var items = new JArray();
if (dset.Contains("itm"))
{
foreach (System.Data.DataRow sitm in dset.Tables("itm").Select(
$"[InvRqId] = '{rdic["Id"]}'",
dset.Tables("itm").Columns.Contains("order") ? "order" : ""))
{
var di = sitm.toObjectDictionary();
double net = Convert.ToDouble(di.no("value_total", 0));
double vat = Convert.ToDouble(di.no("vat", 0));
items.Add(new JObject
{
["Id"] = di["Id"]?.ToString() ?? "",
["net_val"] = net,
["vat_val"] = net * vat * 0.01,
["vat"] = vat == 0 ? "" : vat.ToString("0.00", FuchsPdf.DeCulture) + "%",
["svcnet_val"] = Convert.ToDouble(di.no("value_service", 0)),
["svcvat_val"] = 0,
["net"] = Convert.ToDouble(di.no("value", 0)),
["quantity"] = di.nz("Quantity"),
["Type"] = di.nz("Type"),
["Note"] = di.nz("Text"),
["htmltext"] = di.nz("Text"),
["position"] = di.nz("Position"),
["SortOrder"] = di.nz("SortOrder")
});
}
}
block["items"] = items;
blocks.Add(block);
}
return blocks;
}
// ── token helpers ─────────────────────────────────────────────────────────
private static string Str(JToken? t) =>
t == null || t.Type == JTokenType.Null ? "" : t.Type == JTokenType.String ? t.Value<string>() ?? "" : t.ToString();
private static bool AsBool(JToken? t)
{
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";
}
private static JObject TryParseObject(string json)
{
if (!string.IsNullOrWhiteSpace(json) && json.TrimStart().StartsWith('{'))
{
try { return JObject.Parse(json); } catch { /* fall through */ }
}
return new JObject();
}
}
@@ -0,0 +1,68 @@
using Fuchs.Notifications;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Fuchs.Services;
/// <summary>
/// Background monitor for the invoice draft cache (ADR 0006). Because a draft's
/// truth lives only in server memory until the user saves, idle sessions must not
/// vanish silently: this service warns the editing browser <b>before</b> a session's
/// idle TTL lapses ("bitte zwischenspeichern"), and when the TTL is finally reached
/// it evicts the session and tells the browser to close the editor with a reason.
/// All hints travel over the <see cref="DraftPreviewHub"/> via <see cref="IDraftNotifier"/>.
/// </summary>
public sealed class InvoiceDraftExpiryService : BackgroundService
{
private readonly IInvoiceDraftCache _cache;
private readonly IDraftNotifier _notifier;
private readonly ILogger<InvoiceDraftExpiryService> _logger;
private readonly TimeSpan _warnLead;
private readonly TimeSpan _interval;
public InvoiceDraftExpiryService(IInvoiceDraftCache cache, IDraftNotifier notifier,
IConfiguration configuration, ILogger<InvoiceDraftExpiryService> logger)
{
_cache = cache;
_notifier = notifier;
_logger = logger;
int warnMinutes = configuration.GetValue("Fuchs:DraftEditing:ExpiryWarnMinutes", 5);
_warnLead = TimeSpan.FromMinutes(Math.Clamp(warnMinutes, 1, Math.Max(1, (int)cache.IdleTtl.TotalMinutes - 1)));
_interval = TimeSpan.FromSeconds(30);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(_interval);
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
await SweepAsync(stoppingToken);
}
catch (OperationCanceledException) { /* shutting down */ }
}
/// <summary>One pass over all live sessions. Internal so it can be driven directly from unit tests.</summary>
internal async Task SweepAsync(CancellationToken cancellationToken)
{
DateTime now = DateTime.UtcNow;
foreach (var session in _cache.Snapshot())
{
TimeSpan idle = now - session.LastAccessUtc;
if (idle >= _cache.IdleTtl)
{
_cache.Remove(session.Token);
_logger.LogInformation("Draft {Token} evicted after {Idle} idle (user={User})",
session.Token, idle, session.UserAccountId);
await _notifier.SignalClosedAsync(session.Token, "expired", cancellationToken);
}
else if (idle >= _cache.IdleTtl - _warnLead && !session.ExpiryWarningSent)
{
session.ExpiryWarningSent = true;
int secondsLeft = (int)Math.Max(0, (_cache.IdleTtl - idle).TotalSeconds);
await _notifier.SignalExpiringAsync(session.Token, secondsLeft, cancellationToken);
}
}
}
}
+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; }
}
+62
View File
@@ -284,3 +284,65 @@ $fis.notifications = {
}, 9000);
}
};
/* Live draft-editing client (ADR 0006/0007). Separate SignalR connection to the
dedicated /draftpreview hub; the server signals the *one* browser editing a draft
(group = session token) to re-fetch (draftReady), warns before idle expiry
(draftExpiring), and tells it to close on eviction (draftClosed). The editor
(fis.inv_shared.js) registers the open draft via $fis.draft.bind(token, {...}). */
$fis.draft = {
connection: null,
active: null, /* { token, onReady(version), onExpiring(secondsLeft), onClosed(reason) } */
init: function () {
if (typeof signalR === 'undefined' || this.connection !== null || !$ocms.auth.useraccount_id) {
return;
}
this.connection = new signalR.HubConnectionBuilder()
.withUrl('/draftpreview')
.withAutomaticReconnect()
.build();
this.connection.on('draftReady', (p) => this._dispatch('onReady', p, (p) => p.version));
this.connection.on('draftExpiring', (p) => this._dispatch('onExpiring', p, (p) => p.secondsLeft));
this.connection.on('draftClosed', (p) => this._dispatch('onClosed', p, (p) => p.reason));
/* Re-join the active draft's group after a (re)connect — group membership is
per-connection and is lost when the socket drops. */
this.connection.onreconnected(() => { if (this.active) { this._invoke('JoinDraft', this.active.token); } });
this.connection.onclose(() => {
console.warn('Draft connection closed; retrying in 5s.');
this.connection = null;
setTimeout(() => { this.init(); if (this.active) { this.bind(this.active.token, this.active); } }, 5000);
});
this.start();
},
start: function () {
this.connection.start()
.then(() => { if (this.active) { this._invoke('JoinDraft', this.active.token); } })
.catch((err) => {
console.warn('Draft connection failed to start; retrying in 5s.', err);
this.connection = null;
setTimeout(() => this.init(), 5000);
});
},
/* Registers the currently open draft and joins its signal group. handlers:
{ onReady, onExpiring, onClosed }. */
bind: function (token, handlers) {
if (!token) { return; }
this.active = $.extend({ token: token }, handlers || {});
if (this.connection === null) { this.init(); }
this._invoke('JoinDraft', token);
},
/* Unregisters + leaves the group (editor closed). */
release: function (token) {
if (this.active && (!token || this.active.token === token)) { this.active = null; }
this._invoke('LeaveDraft', token);
},
_invoke: function (method, token) {
if (!token || !this.connection || this.connection.state !== 'Connected') { return; }
this.connection.invoke(method, token).catch((err) => console.warn('Draft ' + method + ' failed', err));
},
_dispatch: function (handler, payload, argOf) {
payload = payload || {};
if (!this.active || this.active.token !== payload.token) { return; }
if (typeof this.active[handler] === 'function') { this.active[handler](argOf(payload)); }
}
};
+1
View File
@@ -1,4 +1,5 @@
$(document).ready(function () {
$fis.notifications.init();
$fis.draft.init();
$fis.ov();
});
+63
View File
@@ -3129,6 +3129,68 @@ $fis.notifications = {
}
};
/* Live draft-editing client (ADR 0006/0007). Separate SignalR connection to the
dedicated /draftpreview hub; the server signals the *one* browser editing a draft
(group = session token) to re-fetch (draftReady), warns before idle expiry
(draftExpiring), and tells it to close on eviction (draftClosed). The editor
(fis.inv_shared.js) registers the open draft via $fis.draft.bind(token, {...}). */
$fis.draft = {
connection: null,
active: null, /* { token, onReady(version), onExpiring(secondsLeft), onClosed(reason) } */
init: function () {
if (typeof signalR === 'undefined' || this.connection !== null || !$ocms.auth.useraccount_id) {
return;
}
this.connection = new signalR.HubConnectionBuilder()
.withUrl('/draftpreview')
.withAutomaticReconnect()
.build();
this.connection.on('draftReady', (p) => this._dispatch('onReady', p, (p) => p.version));
this.connection.on('draftExpiring', (p) => this._dispatch('onExpiring', p, (p) => p.secondsLeft));
this.connection.on('draftClosed', (p) => this._dispatch('onClosed', p, (p) => p.reason));
/* Re-join the active draft's group after a (re)connect — group membership is
per-connection and is lost when the socket drops. */
this.connection.onreconnected(() => { if (this.active) { this._invoke('JoinDraft', this.active.token); } });
this.connection.onclose(() => {
console.warn('Draft connection closed; retrying in 5s.');
this.connection = null;
setTimeout(() => { this.init(); if (this.active) { this.bind(this.active.token, this.active); } }, 5000);
});
this.start();
},
start: function () {
this.connection.start()
.then(() => { if (this.active) { this._invoke('JoinDraft', this.active.token); } })
.catch((err) => {
console.warn('Draft connection failed to start; retrying in 5s.', err);
this.connection = null;
setTimeout(() => this.init(), 5000);
});
},
/* Registers the currently open draft and joins its signal group. handlers:
{ onReady, onExpiring, onClosed }. */
bind: function (token, handlers) {
if (!token) { return; }
this.active = $.extend({ token: token }, handlers || {});
if (this.connection === null) { this.init(); }
this._invoke('JoinDraft', token);
},
/* Unregisters + leaves the group (editor closed). */
release: function (token) {
if (this.active && (!token || this.active.token === token)) { this.active = null; }
this._invoke('LeaveDraft', token);
},
_invoke: function (method, token) {
if (!token || !this.connection || this.connection.state !== 'Connected') { return; }
this.connection.invoke(method, token).catch((err) => console.warn('Draft ' + method + ' failed', err));
},
_dispatch: function (handler, payload, argOf) {
payload = payload || {};
if (!this.active || this.active.token !== payload.token) { return; }
if (typeof this.active[handler] === 'function') { this.active[handler](argOf(payload)); }
}
};
(function () {
Array.prototype.push.apply($ocms.ocmsmenu,[
{ lbl: $t.m_inv, id: 'm_inv', fnc: 'init:inv', ico: 'glyphicon glyphicon-list-alt'}
@@ -3143,5 +3205,6 @@ $fis.notifications = {
})();
$(document).ready(function () {
$fis.notifications.init();
$fis.draft.init();
$fis.ov();
});
+2 -2
View File
File diff suppressed because one or more lines are too long