-
@(EditEntry != null ? "Buchung bearbeiten" : "Neue Buchung")
+
+
@(EditEntry != null ? "Buchung bearbeiten" : "Einnahme oder Ausgabe erfassen") – @AccountName
- @if (OutsideYear)
+ @if (OutsideYear && date != default)
{
Datum @date.ToString("dd.MM.yyyy"): Diese Buchung liegt außerhalb von Jahr @ContextYear und wird dort nicht angezeigt.
@@ -33,12 +48,10 @@
}
-
- @(EditEntry != null ? "Speichern" : "Hinzufügen")
+
+ Speichern
Abbrechen
-
diff --git a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs
index e7bfc0f..fa5ebe5 100644
--- a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs
+++ b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs
@@ -27,12 +27,20 @@ public partial class AddEntryDialog
private int? confirmedYear;
private bool OutsideYear => ContextYear.HasValue && date.Year != ContextYear.Value;
+ [Parameter] public string AccountName { get; set; } = string.Empty;
+ private bool CanSave => !string.IsNullOrWhiteSpace(title) && ValidAmount && date != default && (!OutsideYear || (confirmDifferentYear && confirmedYear == date.Year));
+
#region Fields
private EntryType entryType = EntryType.Income;
- private DateTime date = DateTime.Today;
+ private string dateText = DateTime.Today.ToString("yyyy-MM-dd");
+ private DateTime date => Duempelkas.App.Services.BookingInput.Date(dateText);
private string title = string.Empty;
- private decimal amount;
+ private string amountText = string.Empty;
+ private bool ValidAmount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out _);
+ private decimal amount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out var value) ? value : 0;
+ private bool initialized;
+ private int? initializedEntryId;
#endregion
@@ -40,12 +48,15 @@ public partial class AddEntryDialog
protected override void OnParametersSet()
{
+ if (initialized && initializedEntryId == EditEntry?.Id) return;
+ initialized = true;
+ initializedEntryId = EditEntry?.Id;
if (EditEntry != null)
{
entryType = EditEntry.Type;
- date = EditEntry.Date;
+ dateText = EditEntry.Date.ToString("yyyy-MM-dd");
title = EditEntry.Title;
- amount = EditEntry.Amount;
+ amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
}
}
@@ -55,7 +66,7 @@ public partial class AddEntryDialog
private async Task Save()
{
- if (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)) return;
+ if (!CanSave) return;
if (EditEntry != null)
{
await EntryService.UpdateEntryAsync(EditEntry.Id, date, title.Trim(), amount);
diff --git a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor
index 8012a6d..32f828e 100644
--- a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor
+++ b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor
@@ -2,7 +2,7 @@
@inject IAccountService AccountService
-
+
@if (OutsideYear)
diff --git a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs
index 6e1541c..dfb4989 100644
--- a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs
+++ b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs
@@ -30,15 +30,20 @@ public partial class AddTransferDialog
private List
accounts = new();
private int targetAccountId;
- private DateTime date = DateTime.Today;
+ private string dateText = DateTime.Today.ToString("yyyy-MM-dd");
+ private DateTime date => Duempelkas.App.Services.BookingInput.Date(dateText);
private string title = string.Empty;
- private decimal amount;
+ private string amountText = string.Empty;
+ private bool ValidAmount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out _);
+ private decimal amount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out var value) ? value : 0;
+ private bool initialized;
+ private int? initializedEntryId;
#endregion
#region Properties
- private bool CanSave => targetAccountId > 0 && !string.IsNullOrWhiteSpace(title) && amount > 0;
+ private bool CanSave => targetAccountId > 0 && targetAccountId != SourceAccountId && !string.IsNullOrWhiteSpace(title) && ValidAmount && date != default && (!OutsideYear || (confirmDifferentYear && confirmedYear == date.Year));
#endregion
@@ -51,12 +56,15 @@ public partial class AddTransferDialog
accounts = await AccountService.GetAllAccountsAsync();
}
+ if (initialized && initializedEntryId == EditEntry?.Id) return;
+ initialized = true;
+ initializedEntryId = EditEntry?.Id;
if (EditEntry != null)
{
targetAccountId = EditEntry.LinkedAccountId ?? 0;
- date = EditEntry.Date;
+ dateText = EditEntry.Date.ToString("yyyy-MM-dd");
title = EditEntry.Title;
- amount = EditEntry.Amount;
+ amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
}
}
@@ -66,7 +74,7 @@ public partial class AddTransferDialog
private async Task Save()
{
- if (OutsideYear && (!confirmDifferentYear || confirmedYear != date.Year)) return;
+ if (!CanSave) return;
if (EditEntry != null)
{
await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount);
diff --git a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor
index 002e83a..34bcd11 100644
--- a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor
+++ b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor
@@ -111,7 +111,7 @@
@if (showAddEntry)
{
-
}
@@ -175,7 +175,7 @@
@if (editingEntry != null)
{
-
diff --git a/src/Duempelkas.App/Services/BookingInput.cs b/src/Duempelkas.App/Services/BookingInput.cs
new file mode 100644
index 0000000..d3c7780
--- /dev/null
+++ b/src/Duempelkas.App/Services/BookingInput.cs
@@ -0,0 +1,30 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace Duempelkas.App.Services;
+
+public static partial class BookingInput
+{
+ [GeneratedRegex(@"^\d+([,.]\d{1,2})?$", RegexOptions.CultureInvariant)]
+ private static partial Regex AmountPattern();
+
+ public static bool TryAmount(string text, out decimal amount)
+ {
+ amount = 0;
+ var plain = text.Trim();
+ return AmountPattern().IsMatch(plain)
+ && decimal.TryParse(plain.Replace(',', '.'), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out amount)
+ && amount > 0;
+ }
+
+ public static DateTime Date(string text) => DateTime.TryParseExact(text, "yyyy-MM-dd",
+ CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : default;
+
+ public static void Validate(DateTime date, string title, decimal amount)
+ {
+ if (date == default) throw new ArgumentException("Bitte ein gültiges Datum eingeben.");
+ if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Bitte eine Beschreibung eingeben.");
+ if (amount <= 0 || decimal.Round(amount, 2) != amount)
+ throw new ArgumentException("Betrag ohne Minuszeichen, größer als 0 und mit höchstens zwei Nachkommastellen eingeben.");
+ }
+}
diff --git a/src/Duempelkas.Desktop/wwwroot/css/app.css b/src/Duempelkas.Desktop/wwwroot/css/app.css
index 5971ce9..caae055 100644
--- a/src/Duempelkas.Desktop/wwwroot/css/app.css
+++ b/src/Duempelkas.Desktop/wwwroot/css/app.css
@@ -597,3 +597,8 @@ input[type="number"].no-spinner {
.more-actions { align-self: center; }
.more-actions[open] { padding: .5rem; border: 1px solid var(--color-border); border-radius: .5rem; }
+
+.form-container > div { min-width: 0; }
+.form-container p { margin: .35rem 0 0; font-size: .9rem; }
+
+.booking-dialog { max-height: calc(100vh - 2rem); overflow-y: auto; }
diff --git a/src/Duempelkas.Infrastructure/Services/EntryService.cs b/src/Duempelkas.Infrastructure/Services/EntryService.cs
index f7d9068..7c8eb6c 100644
--- a/src/Duempelkas.Infrastructure/Services/EntryService.cs
+++ b/src/Duempelkas.Infrastructure/Services/EntryService.cs
@@ -99,6 +99,8 @@ public class EntryService : IEntryService
public async Task CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
{
+ if (!Enum.IsDefined(type)) throw new ArgumentException("Ungültige Buchungsart.");
+ BookingInput.Validate(date, title, amount);
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
@@ -110,7 +112,7 @@ public class EntryService : IEntryService
DisplayId = displayId,
Type = type,
Date = date,
- Title = title,
+ Title = title.Trim(),
Amount = amount
};
db.Entries.Add(entry);
@@ -121,6 +123,7 @@ public class EntryService : IEntryService
public async Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount)
{
+ BookingInput.Validate(date, title, amount);
if (sourceAccountId == targetAccountId)
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
@@ -136,7 +139,7 @@ public class EntryService : IEntryService
DisplayId = transferDisplayId,
Type = EntryType.Expense,
Date = date,
- Title = title,
+ Title = title.Trim(),
Amount = amount
};
@@ -149,7 +152,7 @@ public class EntryService : IEntryService
DisplayId = transferDisplayId,
Type = EntryType.Income,
Date = date,
- Title = title,
+ Title = title.Trim(),
Amount = amount
};
@@ -220,6 +223,7 @@ public class EntryService : IEntryService
public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount)
{
+ BookingInput.Validate(date, title, amount);
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
@@ -230,7 +234,7 @@ public class EntryService : IEntryService
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId);
entry.Date = date;
- entry.Title = title;
+ entry.Title = title.Trim();
entry.Amount = amount;
if (link != null)
@@ -240,7 +244,7 @@ public class EntryService : IEntryService
if (otherEntry != null)
{
otherEntry.Date = date;
- otherEntry.Title = title;
+ otherEntry.Title = title.Trim();
otherEntry.Amount = amount;
}
}
@@ -250,6 +254,7 @@ public class EntryService : IEntryService
public async Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount)
{
+ BookingInput.Validate(date, title, amount);
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
@@ -266,10 +271,10 @@ public class EntryService : IEntryService
// Update date, title, amount on both sides
entry.Date = date;
- entry.Title = title;
+ entry.Title = title.Trim();
entry.Amount = amount;
otherEntry.Date = date;
- otherEntry.Title = title;
+ otherEntry.Title = title.Trim();
otherEntry.Amount = amount;
// If the linked account has changed, move the other entry to the new account
diff --git a/tests/Duempelkas.Tests/BookingFormTests.cs b/tests/Duempelkas.Tests/BookingFormTests.cs
new file mode 100644
index 0000000..af09f31
--- /dev/null
+++ b/tests/Duempelkas.Tests/BookingFormTests.cs
@@ -0,0 +1,51 @@
+using Bunit;
+using Duempelkas.App.Components.Dialogs;
+using Duempelkas.App.Services;
+using Duempelkas.App.Services.Models;
+using Duempelkas.Domain.Enums;
+using Duempelkas.Infrastructure.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Duempelkas.Tests;
+
+public class BookingFormTests
+{
+ [Fact]
+ public void Form_ExplainsMissingFields_AndDoesNotRoundInvalidInput()
+ {
+ using var context = new BunitContext();
+ context.Services.AddSingleton(new EntryService(null!));
+ var dialog = context.Render(p => p.Add(x => x.AccountName, "Barkasse"));
+ Assert.Contains("Barkasse", dialog.Find("h5").TextContent);
+ Assert.Contains("Bitte beschreiben", dialog.Markup);
+ Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
+ dialog.Find("#entry-title").Input("Porto");
+ dialog.Find("#entry-amount").Input("12,501");
+ Assert.Equal("12,501", dialog.Find("#entry-amount").GetAttribute("value"));
+ Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
+ dialog.Find("#entry-amount").Input("-12,50");
+ Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
+ dialog.Find("#entry-amount").Input("12,50");
+ Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled"));
+ dialog.Find("#entry-date").Input("");
+ Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
+ Assert.Contains("Bitte ein gültiges Datum", dialog.Markup);
+ dialog.Find("#entry-date").Input("2026-01-02");
+ Assert.False(dialog.Find("button.btn-success").HasAttribute("disabled"));
+ }
+
+ [Fact]
+ public void Edit_ShowsFixedTypeAndCorrectionPath_AndRerenderKeepsInput()
+ {
+ using var context = new BunitContext();
+ context.Services.AddSingleton(new EntryService(null!));
+ var entry = new EntryDto(1, 1, "2026-001", EntryType.Expense, new(2026, 1, 1), "Porto", 4.20m, false, false, null, null, null);
+ var dialog = context.Render(p => p.Add(x => x.EditEntry, entry));
+ Assert.Contains("Ausgabe", dialog.Markup);
+ Assert.Contains("mit der richtigen Art neu erfassen", dialog.Markup);
+ dialog.Find("#entry-title").Input("Korrigierter Text");
+ dialog.Render(p => p.Add(x => x.EditEntry, entry));
+ Assert.Equal("Korrigierter Text", dialog.Find("#entry-title").GetAttribute("value"));
+ }
+}
diff --git a/tests/Duempelkas.Tests/BookingInputTests.cs b/tests/Duempelkas.Tests/BookingInputTests.cs
new file mode 100644
index 0000000..3e13403
--- /dev/null
+++ b/tests/Duempelkas.Tests/BookingInputTests.cs
@@ -0,0 +1,35 @@
+using Duempelkas.App.Services;
+using Xunit;
+
+namespace Duempelkas.Tests;
+
+public class BookingInputTests
+{
+ [Theory]
+ [InlineData("12,50", true)]
+ [InlineData("12.50", true)]
+ [InlineData(" 12,50 ", true)]
+ [InlineData("12,500", false)]
+ [InlineData("-12,50", false)]
+ [InlineData("1.234,56", false)]
+ [InlineData("1,234.56", false)]
+ [InlineData("12,50 €", false)]
+ [InlineData("0", false)]
+ [InlineData("", false)]
+ [InlineData("9999999999999999999999999999999999999", false)]
+ public void DecimalInput_HasExplicitRules(string text, bool valid)
+ {
+ Assert.Equal(valid, BookingInput.TryAmount(text, out var amount));
+ if (valid) Assert.Equal(12.50m, amount);
+ }
+
+ [Fact]
+ public void ServiceValidation_RejectsInvalidData()
+ {
+ Assert.Throws(() => BookingInput.Validate(default, "Test", 1m));
+ Assert.Throws(() => BookingInput.Validate(DateTime.Today, " ", 1m));
+ Assert.Throws(() => BookingInput.Validate(DateTime.Today, "Test", -1m));
+ Assert.Throws(() => BookingInput.Validate(DateTime.Today, "Test", 1.001m));
+ Assert.Equal(default, BookingInput.Date("2026-02-30"));
+ }
+}
diff --git a/tests/Duempelkas.Tests/YearNavigationTests.cs b/tests/Duempelkas.Tests/YearNavigationTests.cs
index ad13731..bea81f8 100644
--- a/tests/Duempelkas.Tests/YearNavigationTests.cs
+++ b/tests/Duempelkas.Tests/YearNavigationTests.cs
@@ -42,9 +42,9 @@ public class YearNavigationTests
page.WaitForElement(".summary-section");
page.FindAll("button").Single(b => b.TextContent.Contains("Einnahme oder Ausgabe erfassen")).Click();
var dialog = page.FindComponent();
- dialog.Find("input[type=date]").Change("2026-01-02");
- dialog.Find("input[type=text]").Change("Außerhalb");
- dialog.Find("input[type=number]").Change("12.50");
+ dialog.Find("input[type=date]").Input("2026-01-02");
+ dialog.Find("#entry-title").Input("Außerhalb");
+ dialog.Find("#entry-amount").Input("12.50");
Assert.Contains("außerhalb", dialog.Markup);
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
dialog.Find("input[type=checkbox]").Change(true);