diff --git a/docs/usability-umsetzung.md b/docs/usability-umsetzung.md index 9c32a4d..c6dc673 100644 --- a/docs/usability-umsetzung.md +++ b/docs/usability-umsetzung.md @@ -75,3 +75,7 @@ Gelöschte Zeilen sind standardmäßig ausgeblendet und gezählt erreichbar; sic ## U11 – Umbuchungsrichtung Beide Seiten zeigen dieselbe Richtung Von → Nach und erklären das feste aktuelle sowie änderbare Gegenkonto. Vorschau zeigt Minus/Plus und unveränderte Vereinssumme; nur ein Konto erklärt die fehlende Voraussetzung. Der Dienst weist beim Bearbeiten gleiche Konten und fehlende Gegenkonten vor jeder Änderung ab. Tests öffnen beide Seiten, ändern die Quelle einer empfangenen Umbuchung und prüfen den unveränderten Bestand nach unzulässiger Änderung. + +## U12 – Anfangsbestand und Bestandskorrektur + +Neue Konten erhalten einen erklärten Anfangsbestand ohne künstliche Einnahme. Spätere Korrektur zeigt Konto/Jahr, Formel, vorher/nachher für gewählten Bestand, gemeinsamen Anfangsbestand und Endstand sowie ausdrücklich die Auswirkungen auf frühere/spätere Jahre. Zustimmung ist erst nach Eingabe gültig, Abbrechen hat Anfangsfokus. Bestehende gemeinsame Semantik bleibt unverändert, keine Migration oder jahresbezogene neue Speicherregel. Dienst prüft den erwarteten Bestand in einer Transaktion und weist veraltete Vorschauen ab. Mehrjahrestest prüft die Verschiebung aller Jahre bei unveränderten Buchungen, Formularprüfung die notwendige Zustimmung. diff --git a/src/Duempelkas.App/Components/Dialogs/AddAccountDialog.razor b/src/Duempelkas.App/Components/Dialogs/AddAccountDialog.razor index c315791..28d97c7 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddAccountDialog.razor +++ b/src/Duempelkas.App/Components/Dialogs/AddAccountDialog.razor @@ -1,4 +1,5 @@ - +@inject ToastService Toasts +
Neues Konto
@@ -6,8 +7,11 @@
+ + +

Geld, das vor der ersten erfassten Buchung vorhanden ist. 0 ist möglich, ein Minus bei überzogenem Konto ebenfalls. Der Anfangsbestand zählt nicht als neue Einnahme.

- +
diff --git a/src/Duempelkas.App/Components/Dialogs/AddAccountDialog.razor.cs b/src/Duempelkas.App/Components/Dialogs/AddAccountDialog.razor.cs index 815724d..45c5c35 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddAccountDialog.razor.cs +++ b/src/Duempelkas.App/Components/Dialogs/AddAccountDialog.razor.cs @@ -7,7 +7,7 @@ public partial class AddAccountDialog #region Parameters [Parameter] - public EventCallback OnSave { get; set; } + public EventCallback OnSave { get; set; } [Parameter] public EventCallback OnCancel { get; set; } @@ -17,6 +17,9 @@ public partial class AddAccountDialog #region Fields private string name = string.Empty; + private string initialAmount = "0,00"; + private bool ValidInitial => Duempelkas.App.Services.BookingInput.TryBalance(initialAmount, out _); + private bool saving; #endregion @@ -24,9 +27,12 @@ public partial class AddAccountDialog private async Task Save() { - if (!string.IsNullOrWhiteSpace(name)) + if (!saving && !string.IsNullOrWhiteSpace(name) && ValidInitial) { - await OnSave.InvokeAsync(name.Trim()); + saving = true; + try { Duempelkas.App.Services.BookingInput.TryBalance(initialAmount, out var initial); await OnSave.InvokeAsync(new(name.Trim(), initial)); } + catch (Exception ex) { Toasts.Show("Das Konto konnte nicht angelegt werden. Die Eingaben bleiben erhalten.", Duempelkas.App.Services.ToastKind.Error, ex.Message); } + finally { saving = false; } } } diff --git a/src/Duempelkas.App/Components/Dialogs/EditCarryoverDialog.razor b/src/Duempelkas.App/Components/Dialogs/EditCarryoverDialog.razor index 294f1dd..fb49f53 100644 --- a/src/Duempelkas.App/Components/Dialogs/EditCarryoverDialog.razor +++ b/src/Duempelkas.App/Components/Dialogs/EditCarryoverDialog.razor @@ -1,13 +1,21 @@ - -
Übertrag bearbeiten
-
- - -
-
- - -
+@inject ToastService Toasts + +
Bestandskorrektur – @AccountName
+

@(Year.HasValue ? $"Übertrag zum 01.01.{Year}" : "Gemeinsamer Anfangsbestand – alle Jahre")

+

Anfangsbestand + Einnahmen − Ausgaben = Kontostand

+

Dies ist eine spätere Korrektur, keine zusätzliche Einnahme oder Ausgabe.

+ + +
+

Gewählter Bestand: @Money(CurrentAmount) → @Money(amount)

+

Gemeinsamer Anfangsbestand: @Money(InitialBalance) → @Money(InitialBalance + Delta)

+

Kontostand der Ansicht: @Money(CurrentEndBalance) → @Money(CurrentEndBalance + Delta)

+

Alle Kontostände dieses Kontos ändern sich um @Money(Delta). Das betrifft frühere und spätere Jahre.

+
+ +
+ + +
- @code { private ModalDialog? modal; } diff --git a/src/Duempelkas.App/Components/Dialogs/EditCarryoverDialog.razor.cs b/src/Duempelkas.App/Components/Dialogs/EditCarryoverDialog.razor.cs index 7f97158..2dc8527 100644 --- a/src/Duempelkas.App/Components/Dialogs/EditCarryoverDialog.razor.cs +++ b/src/Duempelkas.App/Components/Dialogs/EditCarryoverDialog.razor.cs @@ -1,39 +1,36 @@ using Microsoft.AspNetCore.Components; - +using Duempelkas.App.Services; namespace Duempelkas.App.Components.Dialogs; public partial class EditCarryoverDialog { - #region Parameters - - [Parameter] - public decimal CurrentAmount { get; set; } - - [Parameter] - public EventCallback OnSave { get; set; } - - [Parameter] - public EventCallback OnCancel { get; set; } - - #endregion - - #region Fields - - private decimal amount; - - #endregion - - #region Lifecycle - - protected override void OnParametersSet() => amount = CurrentAmount; - - #endregion - - #region Actions - - private async Task Save() => await OnSave.InvokeAsync(amount); - - private async Task Cancel() { if (modal != null) await modal.RequestCloseAsync(); else await OnCancel.InvokeAsync(); } - - #endregion + [Parameter] public decimal CurrentAmount { get; set; } + [Parameter] public decimal InitialBalance { get; set; } + [Parameter] public decimal CurrentEndBalance { get; set; } + [Parameter] public int? Year { get; set; } + [Parameter] public string AccountName { get; set; } = "Konto"; + [Parameter] public EventCallback OnSave { get; set; } + [Parameter] public EventCallback OnCancel { get; set; } + private string amountText = string.Empty; + private bool acknowledged; + private bool initialized; + private bool saving; + private bool ValidAmount => BookingInput.TryBalance(amountText, out _); + private decimal amount => BookingInput.TryBalance(amountText, out var value) ? value : CurrentAmount; + private decimal Delta => amount - CurrentAmount; + private static string Money(decimal value) => value.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " €"; + protected override void OnParametersSet() + { + if (!initialized) { amountText = CurrentAmount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE")); initialized = true; } + } + private void ChangeAmount(string value) { amountText = value; acknowledged = false; } + private async Task Save() + { + if (saving || !ValidAmount || !acknowledged || Delta == 0m) return; + saving = true; + try { await OnSave.InvokeAsync(amount); } + catch (Exception ex) { Toasts.Show("Die Korrektur konnte nicht gespeichert werden. Bitte den aktuellen Bestand prüfen.", ToastKind.Error, ex.Message); } + finally { saving = false; } + } + private Task Cancel() => modal?.RequestCloseAsync() ?? Task.CompletedTask; } diff --git a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor index e28778e..314a144 100644 --- a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor +++ b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor @@ -125,7 +125,7 @@ @if (showEditCarryover) { - } diff --git a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs index 7e8295b..1765a79 100644 --- a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs +++ b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs @@ -110,9 +110,10 @@ public partial class AccountDetail private async Task HandleSaveCarryover(decimal newAmount) { - await AccountService.UpdateCarryoverAsync(AccountId, newAmount, selectedYear); - showEditCarryover = false; + await AccountService.CorrectCarryoverAsync(AccountId, newAmount, selectedYear, balance!.CarryoverBalance, account!.CarryoverBalance); await LoadAll(); + showEditCarryover = false; + Toasts.Show("Bestandskorrektur gespeichert. Die Änderung gilt für alle Jahre.", ToastKind.Success); } private Task HandleEntryCreated(BookingSaved saved) => HandleBookingSaved(saved); diff --git a/src/Duempelkas.App/Pages/Dashboard.razor.cs b/src/Duempelkas.App/Pages/Dashboard.razor.cs index 38021e3..bd7e8d0 100644 --- a/src/Duempelkas.App/Pages/Dashboard.razor.cs +++ b/src/Duempelkas.App/Pages/Dashboard.razor.cs @@ -84,9 +84,9 @@ public partial class Dashboard #region Actions - private async Task HandleAccountCreated(string name) + private async Task HandleAccountCreated(AccountSetup setup) { - await AccountService.CreateAccountAsync(name); + await AccountService.CreateAccountAsync(setup.Name, setup.InitialBalance); showAddAccount = false; await LoadAll(); } diff --git a/src/Duempelkas.App/Services/BookingInput.cs b/src/Duempelkas.App/Services/BookingInput.cs index 920d843..5190d5f 100644 --- a/src/Duempelkas.App/Services/BookingInput.cs +++ b/src/Duempelkas.App/Services/BookingInput.cs @@ -20,6 +20,14 @@ public static partial class BookingInput && amount > 0; } + public static bool TryBalance(string text, out decimal amount) + { + amount = 0; + var plain = text.Trim(); + return Regex.IsMatch(plain, @"^-?\d+([,.]\d{1,2})?$", RegexOptions.CultureInvariant) + && decimal.TryParse(plain.Replace(',', '.'), NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out amount); + } + public static DateTime Date(string text) => DateTime.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : default; diff --git a/src/Duempelkas.App/Services/IAccountService.cs b/src/Duempelkas.App/Services/IAccountService.cs index 17c63ec..0c66efa 100644 --- a/src/Duempelkas.App/Services/IAccountService.cs +++ b/src/Duempelkas.App/Services/IAccountService.cs @@ -8,8 +8,10 @@ public interface IAccountService Task> GetAllAccountsAsync(int? year); Task GetAccountAsync(int accountId); Task CreateAccountAsync(string name); + Task CreateAccountAsync(string name, decimal initialBalance); Task RenameAccountAsync(int accountId, string newName); Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance); Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance, int? year); + Task CorrectCarryoverAsync(int accountId, decimal value, int? year, decimal expectedCarryover, decimal expectedInitial); Task DeleteAccountAsync(int accountId); } diff --git a/src/Duempelkas.App/Services/Models/AccountSetup.cs b/src/Duempelkas.App/Services/Models/AccountSetup.cs new file mode 100644 index 0000000..81eedb9 --- /dev/null +++ b/src/Duempelkas.App/Services/Models/AccountSetup.cs @@ -0,0 +1,3 @@ +namespace Duempelkas.App.Services.Models; + +public sealed record AccountSetup(string Name, decimal InitialBalance); diff --git a/src/Duempelkas.Infrastructure/Services/AccountService.cs b/src/Duempelkas.Infrastructure/Services/AccountService.cs index 030f4d0..8200a8b 100644 --- a/src/Duempelkas.Infrastructure/Services/AccountService.cs +++ b/src/Duempelkas.Infrastructure/Services/AccountService.cs @@ -93,15 +93,19 @@ public class AccountService : IAccountService return new AccountSummaryDto(account.Id, account.Name, account.CarryoverBalance, totalBalance, account.CreatedUtc); } - public async Task CreateAccountAsync(string name) + public Task CreateAccountAsync(string name) => CreateAccountAsync(name, 0m); + + public async Task CreateAccountAsync(string name, decimal initialBalance) { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Bitte einen Kontonamen eingeben."); + if (decimal.Round(initialBalance, 2) != initialBalance) throw new ArgumentException("Ungültiger Anfangsbestand."); using var lease = await DatabaseLease.AcquireAsync(_location); await using var db = await _dbFactory.CreateDbContextAsync(); - var account = new Account { Name = name }; + var account = new Account { Name = name.Trim(), CarryoverBalance = initialBalance }; db.Accounts.Add(account); await db.SaveChangesAsync(); - return new AccountSummaryDto(account.Id, account.Name, 0m, 0m, account.CreatedUtc); + return new AccountSummaryDto(account.Id, account.Name, initialBalance, initialBalance, account.CreatedUtc); } public async Task RenameAccountAsync(int accountId, string newName) @@ -148,6 +152,22 @@ public class AccountService : IAccountService await db.SaveChangesAsync(); } + public async Task CorrectCarryoverAsync(int accountId, decimal value, int? year, decimal expectedCarryover, decimal expectedInitial) + { + if (decimal.Round(value, 2) != value) throw new ArgumentException("Ungültiger Bestand."); + using var lease = await DatabaseLease.AcquireAsync(_location); + await using var db = await _dbFactory.CreateDbContextAsync(); + await using var transaction = await db.Database.BeginTransactionAsync(); + var account = await db.Accounts.FindAsync(accountId) ?? throw new InvalidOperationException("Konto nicht mehr vorhanden."); + var movement = year.HasValue ? await db.Entries.Where(e => e.AccountId == accountId && !e.IsDeleted && e.Date.Year < year.Value) + .SumAsync(e => e.Type == EntryType.Income ? e.Amount : -e.Amount) : 0m; + if (account.CarryoverBalance != expectedInitial || account.CarryoverBalance + movement != expectedCarryover) + throw new InvalidOperationException("Der Bestand wurde inzwischen geändert. Bitte abbrechen und die Korrektur mit aktuellen Werten erneut öffnen."); + account.CarryoverBalance = value - movement; + await db.SaveChangesAsync(); + await transaction.CommitAsync(); + } + public async Task DeleteAccountAsync(int accountId) { using var lease = await DatabaseLease.AcquireAsync(_location); diff --git a/tests/Duempelkas.Tests/CarryoverCorrectionTests.cs b/tests/Duempelkas.Tests/CarryoverCorrectionTests.cs new file mode 100644 index 0000000..b943237 --- /dev/null +++ b/tests/Duempelkas.Tests/CarryoverCorrectionTests.cs @@ -0,0 +1,48 @@ +using Bunit; +using Duempelkas.App.Components.Dialogs; +using Duempelkas.App.Services; +using Duempelkas.Domain.Enums; +using Xunit; + +namespace Duempelkas.Tests; + +public class CarryoverCorrectionTests +{ + [Fact] + public async Task Correction_ShiftsAllYearsByDelta_AndRejectsStaleState() + { + using var f = new LedgerUiFixture(); + var accounts = f.Service(); + var entries = f.Service(); + var balances = f.Service(); + await entries.CreateEntryAsync(1, EntryType.Income, new(2025, 1, 1), "Alt", 10m); + await entries.CreateEntryAsync(1, EntryType.Expense, new(2026, 1, 1), "Neu", 5m); + await accounts.CorrectCarryoverAsync(1, 120m, 2026, 110m, 100m); + Assert.Equal(110m, (await accounts.GetAccountAsync(1)).CarryoverBalance); + Assert.Equal(120m, (await balances.GetAccountBalanceAsync(1, 2025)).TotalBalance); + Assert.Equal(115m, (await balances.GetAccountBalanceAsync(1, 2026)).TotalBalance); + Assert.Equal(115m, (await balances.GetAccountBalanceAsync(1, 2027)).TotalBalance); + Assert.Equal(2, (await entries.GetEntriesAsync(1, (int?)null)).Count); + await Assert.ThrowsAsync(() => accounts.CorrectCarryoverAsync(1, 130m, 2026, 110m, 100m)); + var newAccount = await accounts.CreateAccountAsync("Neues Konto", -20m); + Assert.Equal(-20m, newAccount.TotalBalance); + Assert.Empty(await entries.GetEntriesAsync(newAccount.Id, (int?)null)); + } + + [Fact] + public void CorrectionForm_ShowsBeforeAfter_AndRequiresAcknowledgement() + { + using var f = new LedgerUiFixture(); + var saved = 0m; + var dialog = f.Context.Render(p => p.Add(x => x.CurrentAmount, 110m) + .Add(x => x.InitialBalance, 100m).Add(x => x.Year, 2026).Add(x => x.AccountName, "Barkasse") + .Add(x => x.OnSave, value => saved = value)); + dialog.Find("#carryover").Input("120,00"); + Assert.Contains("frühere und spätere Jahre", dialog.Markup); + Assert.Contains("110,00", dialog.Find(".correction-preview").TextContent); + Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled")); + dialog.Find("input[type=checkbox]").Change(true); + dialog.Find("button.btn-success").Click(); + Assert.Equal(120m, saved); + } +}