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
+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"));
}
}