feat(accounts): preview and confirm cross-year balance corrections (U12)

This commit is contained in:
2026-11-02 19:28:30 +01:00
parent 4d0d5c8d88
commit 45fe327840
13 changed files with 158 additions and 57 deletions
+4
View File
@@ -75,3 +75,7 @@ Gelöschte Zeilen sind standardmäßig ausgeblendet und gezählt erreichbar; sic
## U11 – Umbuchungsrichtung ## 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. 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.
@@ -1,4 +1,5 @@
<ModalDialog @ref="modal" Title="Neues Konto" OnCancel="OnCancel" Dirty="@(!string.IsNullOrWhiteSpace(name))"> @inject ToastService Toasts
<ModalDialog @ref="modal" Title="Neues Konto" OnCancel="OnCancel" Dirty="@(!string.IsNullOrWhiteSpace(name) || initialAmount != "0,00")" Busy="saving">
<h5 class="mb-3">Neues Konto</h5> <h5 class="mb-3">Neues Konto</h5>
<form @onsubmit="Save" @onsubmit:preventDefault="true"> <form @onsubmit="Save" @onsubmit:preventDefault="true">
<div class="form-container"> <div class="form-container">
@@ -6,8 +7,11 @@
<input id="account-name" data-initial-focus type="text" class="form-control" @bind="name" @bind:event="oninput" <input id="account-name" data-initial-focus type="text" class="form-control" @bind="name" @bind:event="oninput"
placeholder="z.B. Girokonto" /> placeholder="z.B. Girokonto" />
</div> </div>
<label for="account-initial">Anfangsbestand (€)</label>
<AmountInput Id="account-initial" @bind-Value="initialAmount" AllowNegative="true" Disabled="saving" />
<p>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.</p>
<div class="d-flex justify-content-end gap-2"> <div class="d-flex justify-content-end gap-2">
<button type="submit" class="btn btn-success" disabled="@string.IsNullOrWhiteSpace(name)"><i class="bi bi-check-lg"></i> Erstellen</button> <button type="submit" class="btn btn-success" disabled="@(saving || string.IsNullOrWhiteSpace(name) || !ValidInitial)"><i class="bi bi-check-lg"></i> Erstellen</button>
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button> <button type="button" class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div> </div>
</form> </form>
@@ -7,7 +7,7 @@ public partial class AddAccountDialog
#region Parameters #region Parameters
[Parameter] [Parameter]
public EventCallback<string> OnSave { get; set; } public EventCallback<Duempelkas.App.Services.Models.AccountSetup> OnSave { get; set; }
[Parameter] [Parameter]
public EventCallback OnCancel { get; set; } public EventCallback OnCancel { get; set; }
@@ -17,6 +17,9 @@ public partial class AddAccountDialog
#region Fields #region Fields
private string name = string.Empty; private string name = string.Empty;
private string initialAmount = "0,00";
private bool ValidInitial => Duempelkas.App.Services.BookingInput.TryBalance(initialAmount, out _);
private bool saving;
#endregion #endregion
@@ -24,9 +27,12 @@ public partial class AddAccountDialog
private async Task Save() 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; }
} }
} }
@@ -1,13 +1,21 @@
<ModalDialog @ref="modal" Title="Bestand korrigieren" OnCancel="OnCancel" Dirty="@(amount != CurrentAmount)"> @inject ToastService Toasts
<h5 class="mb-3">Übertrag bearbeiten</h5> <ModalDialog @ref="modal" Title="Bestandskorrektur" OnCancel="OnCancel" Dirty="@(amount != CurrentAmount)" Busy="saving">
<div class="form-container"> <h5>Bestandskorrektur – @AccountName</h5>
<label class="form-label" for="carryover">Übertrag (€)</label> <p>@(Year.HasValue ? $"Übertrag zum 01.01.{Year}" : "Gemeinsamer Anfangsbestand – alle Jahre")</p>
<input id="carryover" data-initial-focus type="number" class="form-control no-spinner" step="0.01" @bind="amount" /> <p>Anfangsbestand + Einnahmen − Ausgaben = Kontostand</p>
<p>Dies ist eine spätere Korrektur, keine zusätzliche Einnahme oder Ausgabe.</p>
<label for="carryover">@(Year.HasValue ? "Neuer Übertrag (€)" : "Neuer Anfangsbestand (€)")</label>
<AmountInput Id="carryover" Value="amountText" ValueChanged="ChangeAmount" AllowNegative="true" Disabled="saving" />
<div class="correction-preview">
<p>Gewählter Bestand: @Money(CurrentAmount) → @Money(amount)</p>
<p>Gemeinsamer Anfangsbestand: @Money(InitialBalance) → @Money(InitialBalance + Delta)</p>
<p>Kontostand der Ansicht: @Money(CurrentEndBalance) → @Money(CurrentEndBalance + Delta)</p>
<p><strong>Alle Kontostände dieses Kontos ändern sich um @Money(Delta). Das betrifft frühere und spätere Jahre.</strong></p>
</div> </div>
<div class="d-flex justify-content-end gap-2"> <label><input type="checkbox" @bind="acknowledged" disabled="@saving" /> Ich habe die Auswirkungen auf alle Jahre geprüft.</label>
<button type="button" class="btn btn-success" @onclick="Save"><i class="bi bi-check-lg"></i> Speichern</button> <div class="dialog-actions">
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button> <button type="button" class="btn btn-success" disabled="@(saving || !ValidAmount || !acknowledged || Delta == 0m)" @onclick="Save">Korrektur speichern</button>
<button type="button" class="btn btn-outline-secondary" disabled="@saving" data-safe-focus @onclick="Cancel">Abbrechen</button>
</div> </div>
</ModalDialog> </ModalDialog>
@code { private ModalDialog? modal; } @code { private ModalDialog? modal; }
@@ -1,39 +1,36 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Duempelkas.App.Services;
namespace Duempelkas.App.Components.Dialogs; namespace Duempelkas.App.Components.Dialogs;
public partial class EditCarryoverDialog public partial class EditCarryoverDialog
{ {
#region Parameters [Parameter] public decimal CurrentAmount { get; set; }
[Parameter] public decimal InitialBalance { get; set; }
[Parameter] [Parameter] public decimal CurrentEndBalance { get; set; }
public decimal CurrentAmount { get; set; } [Parameter] public int? Year { get; set; }
[Parameter] public string AccountName { get; set; } = "Konto";
[Parameter] [Parameter] public EventCallback<decimal> OnSave { get; set; }
public EventCallback<decimal> OnSave { get; set; } [Parameter] public EventCallback OnCancel { get; set; }
private string amountText = string.Empty;
[Parameter] private bool acknowledged;
public EventCallback OnCancel { get; set; } private bool initialized;
private bool saving;
#endregion private bool ValidAmount => BookingInput.TryBalance(amountText, out _);
private decimal amount => BookingInput.TryBalance(amountText, out var value) ? value : CurrentAmount;
#region Fields private decimal Delta => amount - CurrentAmount;
private static string Money(decimal value) => value.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) + " €";
private decimal amount; protected override void OnParametersSet()
{
#endregion if (!initialized) { amountText = CurrentAmount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE")); initialized = true; }
}
#region Lifecycle private void ChangeAmount(string value) { amountText = value; acknowledged = false; }
private async Task Save()
protected override void OnParametersSet() => amount = CurrentAmount; {
if (saving || !ValidAmount || !acknowledged || Delta == 0m) return;
#endregion saving = true;
try { await OnSave.InvokeAsync(amount); }
#region Actions 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 async Task Save() => await OnSave.InvokeAsync(amount); }
private Task Cancel() => modal?.RequestCloseAsync() ?? Task.CompletedTask;
private async Task Cancel() { if (modal != null) await modal.RequestCloseAsync(); else await OnCancel.InvokeAsync(); }
#endregion
} }
@@ -125,7 +125,7 @@
@if (showEditCarryover) @if (showEditCarryover)
{ {
<EditCarryoverDialog CurrentAmount="@(balance?.CarryoverBalance ?? 0)" <EditCarryoverDialog AccountName="@(account?.Name ?? "")" Year="selectedYear" InitialBalance="@(account?.CarryoverBalance ?? 0)" CurrentEndBalance="@(balance?.TotalBalance ?? 0)" CurrentAmount="@(balance?.CarryoverBalance ?? 0)"
OnSave="HandleSaveCarryover" OnSave="HandleSaveCarryover"
OnCancel="() => showEditCarryover = false" /> OnCancel="() => showEditCarryover = false" />
} }
@@ -110,9 +110,10 @@ public partial class AccountDetail
private async Task HandleSaveCarryover(decimal newAmount) private async Task HandleSaveCarryover(decimal newAmount)
{ {
await AccountService.UpdateCarryoverAsync(AccountId, newAmount, selectedYear); await AccountService.CorrectCarryoverAsync(AccountId, newAmount, selectedYear, balance!.CarryoverBalance, account!.CarryoverBalance);
showEditCarryover = false;
await LoadAll(); await LoadAll();
showEditCarryover = false;
Toasts.Show("Bestandskorrektur gespeichert. Die Änderung gilt für alle Jahre.", ToastKind.Success);
} }
private Task HandleEntryCreated(BookingSaved saved) => HandleBookingSaved(saved); private Task HandleEntryCreated(BookingSaved saved) => HandleBookingSaved(saved);
+2 -2
View File
@@ -84,9 +84,9 @@ public partial class Dashboard
#region Actions #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; showAddAccount = false;
await LoadAll(); await LoadAll();
} }
@@ -20,6 +20,14 @@ public static partial class BookingInput
&& amount > 0; && 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", public static DateTime Date(string text) => DateTime.TryParseExact(text, "yyyy-MM-dd",
CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : default; CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : default;
@@ -8,8 +8,10 @@ public interface IAccountService
Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year); Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year);
Task<AccountSummaryDto> GetAccountAsync(int accountId); Task<AccountSummaryDto> GetAccountAsync(int accountId);
Task<AccountSummaryDto> CreateAccountAsync(string name); Task<AccountSummaryDto> CreateAccountAsync(string name);
Task<AccountSummaryDto> CreateAccountAsync(string name, decimal initialBalance);
Task RenameAccountAsync(int accountId, string newName); Task RenameAccountAsync(int accountId, string newName);
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance); Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance);
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance, int? year); Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance, int? year);
Task CorrectCarryoverAsync(int accountId, decimal value, int? year, decimal expectedCarryover, decimal expectedInitial);
Task DeleteAccountAsync(int accountId); Task DeleteAccountAsync(int accountId);
} }
@@ -0,0 +1,3 @@
namespace Duempelkas.App.Services.Models;
public sealed record AccountSetup(string Name, decimal InitialBalance);
@@ -93,15 +93,19 @@ public class AccountService : IAccountService
return new AccountSummaryDto(account.Id, account.Name, account.CarryoverBalance, totalBalance, account.CreatedUtc); return new AccountSummaryDto(account.Id, account.Name, account.CarryoverBalance, totalBalance, account.CreatedUtc);
} }
public async Task<AccountSummaryDto> CreateAccountAsync(string name) public Task<AccountSummaryDto> CreateAccountAsync(string name) => CreateAccountAsync(name, 0m);
public async Task<AccountSummaryDto> 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); using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync(); 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); db.Accounts.Add(account);
await db.SaveChangesAsync(); 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) public async Task RenameAccountAsync(int accountId, string newName)
@@ -148,6 +152,22 @@ public class AccountService : IAccountService
await db.SaveChangesAsync(); 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) public async Task DeleteAccountAsync(int accountId)
{ {
using var lease = await DatabaseLease.AcquireAsync(_location); using var lease = await DatabaseLease.AcquireAsync(_location);
@@ -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<IAccountService>();
var entries = f.Service<IEntryService>();
var balances = f.Service<IBalanceQueryService>();
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<InvalidOperationException>(() => 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<EditCarryoverDialog>(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);
}
}