Changed Reminder Systematics analogue to invoices
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
using System.Globalization;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side, pure aggregation of a reminder draft's open amount — the authoritative
|
||||
/// replacement for the browser's inline figure (ADR 0006, mirroring
|
||||
/// <see cref="InvoiceDraftCalculator"/>). The user's requirement is that the computed
|
||||
/// figure lives in the backend cache, not the frontend.
|
||||
///
|
||||
/// A reminder chases a single invoiced amount: <c>AmountOpen = AmountTotal - AmountPayed</c>
|
||||
/// (both read from the editor's <c>new</c> block). Static/pure, hence exhaustively
|
||||
/// unit-testable.
|
||||
/// </summary>
|
||||
public static class ReminderDraftCalculator
|
||||
{
|
||||
/// <summary>Recomputes the reminder's open amount from the edited <c>amount</c> / <c>amount_payed</c>.</summary>
|
||||
public static void RecomputeTotals(ReminderDraftSession session)
|
||||
{
|
||||
decimal total = Dec(session.New["amount"]);
|
||||
decimal payed = Dec(session.New["amount_payed"]);
|
||||
session.Sums = new ReminderDraftSums
|
||||
{
|
||||
AmountTotal = total,
|
||||
AmountPayed = payed,
|
||||
AmountOpen = total - payed
|
||||
};
|
||||
}
|
||||
|
||||
/// <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(ReminderDraftSession session)
|
||||
{
|
||||
session.ValidationMessages.Clear();
|
||||
void Add(string field, string sev, string msg) =>
|
||||
session.ValidationMessages.Add(new ReminderDraftValidationMessage(field, sev, msg));
|
||||
|
||||
string email = Str(session.New["invoiceemail"]).Trim();
|
||||
if (email.Length == 0)
|
||||
Add("email", "warning", "Es ist keine E-Mail-Adresse hinterlegt — die Mahnung kann nicht per E-Mail versandt werden.");
|
||||
else if (!IsValidEmail(email))
|
||||
Add("email", "error", "Die E-Mail-Adresse ist ungültig.");
|
||||
|
||||
if (Str(session.New["invoiceaddress"]).Trim().Length == 0)
|
||||
Add("address", "warning", "Es ist keine Anschrift hinterlegt.");
|
||||
|
||||
if (Str(session.New["subject"]).Trim().Length == 0)
|
||||
Add("subject", "warning", "Es ist kein Betreff hinterlegt.");
|
||||
|
||||
if (session.Sums.AmountOpen <= 0)
|
||||
Add("amount", "warning", "Der offene Betrag ist null oder negativ — es besteht keine offene Forderung.");
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
/// <summary>Parses a JToken to a decimal, tolerating German ("12,50" / "1.234,56") and invariant ("12.50") strings.</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 ParseAmount(Str(token));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a currency string, resolving the German/invariant ambiguity: a value with both
|
||||
/// separators treats "." as thousands and "," as decimal ("1.234,56"); a value with only ","
|
||||
/// treats it as the decimal separator ("12,50"); otherwise it is parsed invariant ("1234.56").
|
||||
/// </summary>
|
||||
internal static decimal ParseAmount(string? raw)
|
||||
{
|
||||
string s = (raw ?? "").Trim();
|
||||
if (s.Length == 0) return 0;
|
||||
bool hasComma = s.Contains(','), hasDot = s.Contains('.');
|
||||
if (hasComma && hasDot) s = s.Replace(".", "").Replace(',', '.'); // German "1.234,56"
|
||||
else if (hasComma) s = s.Replace(',', '.'); // German "12,50"
|
||||
return decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal d) ? d : 0;
|
||||
}
|
||||
|
||||
private static string Str(JToken? token) =>
|
||||
token == null || token.Type == JTokenType.Null ? "" : token.Type == JTokenType.String ? token.Value<string>() ?? "" : token.ToString();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side, in-memory editing state for a single reminder (Zahlungserinnerung)
|
||||
/// draft — the authoritative source of truth while a back-office user edits a draft in
|
||||
/// the browser. This mirrors <see cref="InvoiceDraftSession"/> for reminders (ADR 0006):
|
||||
/// the browser is a pure view/input layer that posts single changes
|
||||
/// (<see cref="Fuchs.Services.ReminderDraftDelta"/>); the server mutates this session,
|
||||
/// recomputes the open amount 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.IReminderDraftService"/> (mirroring the
|
||||
/// <see cref="FdsReminderData"/> / <see cref="Fuchs.Services.IReminderService"/> split).
|
||||
/// The editable payload is kept as the exact JSON shape the editor already speaks
|
||||
/// (<c>new</c> / <c>rem</c>), so flushing to the DB can reuse
|
||||
/// <see cref="Fuchs.Services.IReminderService.RegisterReminderAsync"/> unchanged.
|
||||
/// The change-history record type (<see cref="ChangeHistoryEntry"/>) is shared with the
|
||||
/// invoice draft; validation messages use the reminder-specific
|
||||
/// <see cref="ReminderDraftValidationMessage"/>.
|
||||
/// </summary>
|
||||
public sealed class ReminderDraftSession
|
||||
{
|
||||
/// <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 reminder id once the session has been flushed (Zwischenspeichern); empty while cache-only.</summary>
|
||||
public string RemId { 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>Recipient/new fields: subject, invoiceaddress, invoiceemail, text, amount, amount_payed, CustomValues…</summary>
|
||||
public JObject New { get; set; } = new();
|
||||
|
||||
/// <summary>Reference fields: invid, type, level, invoiceid, invoicedate, sender…</summary>
|
||||
public JObject Rem { get; set; } = new();
|
||||
|
||||
// ── Computed (by the draft service; never trusted from the client) ───────
|
||||
/// <summary>Server-computed open-amount aggregation — the values the client used to compute inline.</summary>
|
||||
public ReminderDraftSums Sums { get; set; } = new();
|
||||
|
||||
/// <summary>Plausibility / consistency results, refreshed on every recompute.</summary>
|
||||
public List<ReminderDraftValidationMessage> 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 reminder totals — the authoritative open-amount for the draft.</summary>
|
||||
public sealed class ReminderDraftSums
|
||||
{
|
||||
/// <summary>Invoiced amount (gross) the reminder chases.</summary>
|
||||
public decimal AmountTotal { get; set; }
|
||||
/// <summary>Amount already paid against the invoice.</summary>
|
||||
public decimal AmountPayed { get; set; }
|
||||
/// <summary>Still-open amount (<c>AmountTotal - AmountPayed</c>) — the reminder's headline figure.</summary>
|
||||
public decimal AmountOpen { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A single plausibility/consistency finding for the reminder draft.</summary>
|
||||
/// <param name="Field">Logical field the message relates to (e.g. "email", "address", "amount").</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 ReminderDraftValidationMessage(string Field, string Severity, string Message);
|
||||
Reference in New Issue
Block a user