diff --git a/Schaad.Accounting.Common/Datasets/Charts/CategoryTotal.cs b/Schaad.Accounting.Common/Datasets/Charts/CategoryTotal.cs new file mode 100644 index 0000000..346e6d0 --- /dev/null +++ b/Schaad.Accounting.Common/Datasets/Charts/CategoryTotal.cs @@ -0,0 +1,4 @@ +namespace Schaad.Accounting.Datasets.Charts +{ + public record CategoryTotal(string Name, decimal Value); +} diff --git a/Schaad.Accounting.Common/Datasets/TransactionDataset.cs b/Schaad.Accounting.Common/Datasets/TransactionDataset.cs index b080b64..9e0f7fd 100644 --- a/Schaad.Accounting.Common/Datasets/TransactionDataset.cs +++ b/Schaad.Accounting.Common/Datasets/TransactionDataset.cs @@ -33,5 +33,15 @@ namespace Schaad.Accounting.Datasets else return Value; } + + public decimal GetValue(bool withFxRate, string accountId) + { + if (accountId == OriginAccountId) + { + return -1 * GetValue(withFxRate); + } + + return GetValue(withFxRate); + } } } \ No newline at end of file diff --git a/Schaad.Accounting.Services/ChartService.cs b/Schaad.Accounting.Services/ChartService.cs index 6a72fb0..da00b59 100644 --- a/Schaad.Accounting.Services/ChartService.cs +++ b/Schaad.Accounting.Services/ChartService.cs @@ -95,6 +95,49 @@ namespace Schaad.Accounting.Services return list; } + public async Task> GetBookingTextExpensesPerMonthAsync(string accountId) + { + var account = await viewService.GetAccountViewAsync(accountId); + var transactions = await viewService.GetTransactionViewListAsync(accountId); + if (transactions.Count == 0) + { + return new List(); + } + + var year = settingsService.GetYear(); + var maxMonth = transactions.Max(t => t.ValueDate.Month); + + var list = new List(); + foreach (var grp in transactions.GroupBy(t => t.Text)) + { + var groupedByMonth = grp + .GroupBy(t => t.ValueDate.Month) + .ToDictionary(g => g.Key, g => g.Sum(t => t.GetValue(account.IsFxAccount, accountId))); + EnsureEntryForEveryMonth(groupedByMonth, maxMonth); + + list.Add(new DataSerie( + Id: grp.Key, + Name: grp.Key, + X: groupedByMonth.OrderBy(g => g.Key).Select(g => new DateOnly(year, g.Key, 1)).ToList(), + Y: groupedByMonth.OrderBy(g => g.Key).Select(g => g.Value).ToList() + )); + } + return list; + } + + public async Task> GetBookingTextTotalsAsync(string accountId) + { + var account = await viewService.GetAccountViewAsync(accountId); + var transactions = await viewService.GetTransactionViewListAsync(accountId); + + return transactions + .Where(t => t.TargetAccountId == accountId) + .GroupBy(t => t.Text) + .Select(g => new CategoryTotal(g.Key, g.Sum(t => t.GetValue(account.IsFxAccount, accountId)))) + .Where(c => c.Value != 0) + .ToList(); + } + private static void EnsureEntryForEveryMonth(Dictionary values, int maxMonth = 12) { for (int i = 1; i <= maxMonth; i++) diff --git a/Schaad.Accounting.Services/Interfaces/IChartService.cs b/Schaad.Accounting.Services/Interfaces/IChartService.cs index c722964..64296b1 100644 --- a/Schaad.Accounting.Services/Interfaces/IChartService.cs +++ b/Schaad.Accounting.Services/Interfaces/IChartService.cs @@ -7,5 +7,9 @@ namespace Schaad.Accounting.Interfaces public interface IChartService { Task> GetExpensesPerMonthAsync(); + + Task> GetBookingTextExpensesPerMonthAsync(string accountId); + + Task> GetBookingTextTotalsAsync(string accountId); } } diff --git a/Schaad.Accounting.Services/ViewService.cs b/Schaad.Accounting.Services/ViewService.cs index b59682f..abcabce 100644 --- a/Schaad.Accounting.Services/ViewService.cs +++ b/Schaad.Accounting.Services/ViewService.cs @@ -132,8 +132,9 @@ namespace Schaad.Accounting.Services } /// - /// Get transaction list filtered to a single account, with values sign-flipped - /// when the requested account is the origin of an activa booking. + /// Get transaction list filtered to a single account. Sign is not baked in here - + /// callers use with this + /// account's id to get a value that's negative when the account is paying out. /// public async Task> GetTransactionViewListAsync(string accountId) { @@ -143,22 +144,10 @@ namespace Schaad.Accounting.Services return transactionList.Select(t => new TransactionDataset( - WithDisplaySign(t), + t, accountsById[t.OriginAccountId], accountsById[t.TargetAccountId])) .ToList(); - - Transaction WithDisplaySign(Transaction t) - { - var originAccount = accountsById[t.OriginAccountId]; - if (originAccount.Class == ClassIds.Activa && accountId == t.OriginAccountId) - { - var copy = t.Clone(); - copy.Value *= -1; - return copy; - } - return t; - } } /// diff --git a/Schaad.Accounting.Tests/ChartServiceTestShould.cs b/Schaad.Accounting.Tests/ChartServiceTestShould.cs index decedb4..1e6dcb9 100644 --- a/Schaad.Accounting.Tests/ChartServiceTestShould.cs +++ b/Schaad.Accounting.Tests/ChartServiceTestShould.cs @@ -118,7 +118,7 @@ public class ChartServiceTestShould private static AccountDataset AsDataset(Account a, string subClassName = "") => new(a, balance: 0, balanceCHF: 0, startBalanceCHF: 0, className: "", subClassName: subClassName); - private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, DateTime valueDate, decimal? fxRate = null) => + private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, DateTime valueDate, decimal? fxRate = null, string text = "") => new( new Transaction { @@ -127,6 +127,7 @@ public class ChartServiceTestShould TargetAccountId = target.Id, Value = value, FxRate = fxRate, + Text = text, ValueDate = valueDate, BookingDate = valueDate }, @@ -168,4 +169,113 @@ public class ChartServiceTestShould series.Single(s => s.Id == "42").Y[0].ShouldBe(200m); } + + [Fact] + public async Task GroupBookingTextExpensesByTextWhenBuildingMonthlySeriesForAccount() + { + var checking = MakeAccount("chk", 1010); + var groceries = MakeAccount("groc", 4200); + viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking)); + viewService.GetTransactionViewListAsync("chk").Returns(new List + { + MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"), + MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 2, 3), text: "Coop") + }); + + var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk"); + + series.Count.ShouldBe(2); + var migros = series.Single(s => s.Name == "Migros"); + migros.Y[0].ShouldBe(-50m); + migros.Y[1].ShouldBe(0m); + var coop = series.Single(s => s.Name == "Coop"); + coop.Y[0].ShouldBe(0m); + coop.Y[1].ShouldBe(-20m); + } + + [Fact] + public async Task ShowPositiveValueWhenAccountIsTargetOfBookingTextTransaction() + { + var checking = MakeAccount("chk", 1010); + var salary = MakeAccount("sal", 3000); + viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking)); + viewService.GetTransactionViewListAsync("chk").Returns(new List + { + MakeTransaction(salary, checking, value: 5000m, valueDate: new DateTime(2026, 1, 5), text: "Lohn") + }); + + var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk"); + + series.Single().Y.Single().ShouldBe(5000m); + } + + [Fact] + public async Task ConvertFxAccountValuesWhenBuildingBookingTextSeries() + { + var usChecking = MakeAccount("chk", 1010, currency: "USD"); + var groceries = MakeAccount("groc", 4200); + viewService.GetAccountViewAsync("chk").Returns(AsDataset(usChecking)); + viewService.GetTransactionViewListAsync("chk").Returns(new List + { + MakeTransaction(usChecking, groceries, value: 100m, valueDate: new DateTime(2026, 1, 5), fxRate: 0.5m, text: "Migros") + }); + + var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk"); + + series.Single().Y.Single().ShouldBe(-200m); + } + + [Fact] + public async Task SumBookingTextTotalsAcrossMonthsWhenBuildingTotalsForAccount() + { + var checking = MakeAccount("chk", 1010); + var groceries = MakeAccount("groc", 4200); + viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking)); + viewService.GetTransactionViewListAsync("chk").Returns(new List + { + MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"), + MakeTransaction(checking, groceries, value: 30m, valueDate: new DateTime(2026, 2, 3), text: "Migros"), + MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 2, 4), text: "Coop") + }); + + var totals = await BuildService().GetBookingTextTotalsAsync("chk"); + + totals.Count.ShouldBe(2); + totals.ShouldContain(t => t.Name == "Migros" && t.Value == -80m); + totals.ShouldContain(t => t.Name == "Coop" && t.Value == -20m); + } + + [Fact] + public async Task NetOutgoingAndIncomingBookingsWithTheSameTextWhenBuildingTotalsForAccount() + { + var checking = MakeAccount("chk", 1010); + var groceries = MakeAccount("groc", 4200); + viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking)); + viewService.GetTransactionViewListAsync("chk").Returns(new List + { + MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"), + MakeTransaction(groceries, checking, value: 20m, valueDate: new DateTime(2026, 1, 6), text: "Migros") + }); + + var totals = await BuildService().GetBookingTextTotalsAsync("chk"); + + totals.Single().Value.ShouldBe(-30m); + } + + [Fact] + public async Task ExcludeZeroNetBookingTextsWhenBuildingTotalsForAccount() + { + var checking = MakeAccount("chk", 1010); + var groceries = MakeAccount("groc", 4200); + viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking)); + viewService.GetTransactionViewListAsync("chk").Returns(new List + { + MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"), + MakeTransaction(checking, groceries, value: -50m, valueDate: new DateTime(2026, 1, 6), text: "Migros") + }); + + var totals = await BuildService().GetBookingTextTotalsAsync("chk"); + + totals.ShouldBeEmpty(); + } } diff --git a/Schaad.Accounting.Tests/TransactionDatasetTestShould.cs b/Schaad.Accounting.Tests/TransactionDatasetTestShould.cs new file mode 100644 index 0000000..db931cd --- /dev/null +++ b/Schaad.Accounting.Tests/TransactionDatasetTestShould.cs @@ -0,0 +1,54 @@ +using Schaad.Accounting.Datasets; +using Schaad.Accounting.Models; +using Shouldly; + +namespace Schaad.Accounting.Tests; + +public class TransactionDatasetTestShould +{ + private static Account MakeAccount(string id) => new() { Id = id, Name = id, Currency = "CHF" }; + + private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, decimal? fxRate = null) => + new( + new Transaction + { + Id = Guid.NewGuid().ToString(), + OriginAccountId = origin.Id, + TargetAccountId = target.Id, + Value = value, + FxRate = fxRate, + ValueDate = DateTime.Now, + BookingDate = DateTime.Now + }, + origin, target); + + [Fact] + public void ReturnNegativeValueWhenAccountIsOrigin() + { + var checking = MakeAccount("chk"); + var groceries = MakeAccount("groc"); + var transaction = MakeTransaction(checking, groceries, value: 50m); + + transaction.GetValue(withFxRate: false, accountId: "chk").ShouldBe(-50m); + } + + [Fact] + public void ReturnPositiveValueWhenAccountIsTarget() + { + var checking = MakeAccount("chk"); + var groceries = MakeAccount("groc"); + var transaction = MakeTransaction(groceries, checking, value: 50m); + + transaction.GetValue(withFxRate: false, accountId: "chk").ShouldBe(50m); + } + + [Fact] + public void ApplyFxConversionBeforeApplyingSignWhenAccountIsOrigin() + { + var checking = MakeAccount("chk"); + var groceries = MakeAccount("groc"); + var transaction = MakeTransaction(checking, groceries, value: 100m, fxRate: 0.5m); + + transaction.GetValue(withFxRate: true, accountId: "chk").ShouldBe(-200m); + } +} diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor new file mode 100644 index 0000000..c6079a4 --- /dev/null +++ b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor @@ -0,0 +1,8 @@ +@if (config is not null && layout is not null && data is not null) +{ + +} +else +{ + +} diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor.cs b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor.cs new file mode 100644 index 0000000..d8b6e8f --- /dev/null +++ b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextBreakdown.razor.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Components; +using Plotly.Blazor; +using Plotly.Blazor.Traces; +using Schaad.Accounting.Interfaces; + +namespace Schaad.Accounting.UI.Components.Pages.Charts; + +public partial class BookingTextBreakdown : ComponentBase +{ + private PlotlyChart chart = null!; + private Config? config; + private Plotly.Blazor.Layout? layout; + private IList? data; + + [Parameter, EditorRequired] + public string AccountId { get; set; } = null!; + + [Inject] + private IChartService chartService { get; set; } = null!; + + protected override async Task OnInitializedAsync() + { + config = new Config + { + Responsive = true + }; + + layout = new Plotly.Blazor.Layout + { + Title = new Plotly.Blazor.LayoutLib.Title { Text = "Nach Buchungstext" } + }; + + var totals = await chartService.GetBookingTextTotalsAsync(AccountId); + data = new List + { + new Pie + { + // Values are shown as their absolute magnitude - a pie slice can't represent a signed amount. + Values = totals.Select(t => (object)Math.Abs(t.Value)).ToList(), + Labels = totals.Select(t => (object)t.Name).ToList() + } + }; + } +} diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor new file mode 100644 index 0000000..659020f --- /dev/null +++ b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor @@ -0,0 +1,8 @@ +@if (config is not null && layout is not null) +{ + +} +else +{ + +} diff --git a/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor.cs b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor.cs new file mode 100644 index 0000000..732fb71 --- /dev/null +++ b/Schaad.Accounting.UI/Components/Pages/Charts/BookingTextOverTime.razor.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Components; +using Plotly.Blazor; +using Plotly.Blazor.LayoutLib; +using Plotly.Blazor.Traces; +using Schaad.Accounting.Interfaces; + +namespace Schaad.Accounting.UI.Components.Pages.Charts; + +public partial class BookingTextOverTime : ComponentBase +{ + private PlotlyChart chart = null!; + private Config? config; + private Plotly.Blazor.Layout? layout; + private IList data = new List(); + + [Parameter, EditorRequired] + public string AccountId { get; set; } = null!; + + [Inject] + private IChartService chartService { get; set; } = null!; + + protected override async Task OnInitializedAsync() + { + config = new Config + { + Responsive = true + }; + + layout = new Plotly.Blazor.Layout + { + Title = new Title { Text = "Nach Buchungstext im Verlauf" }, + BarMode = BarModeEnum.Stack, + XAxis = new List + { + new() + { + Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Date + } + } + }; + + var newData = new List(); + var dataSeries = await chartService.GetBookingTextExpensesPerMonthAsync(AccountId); + foreach (var dataSerie in dataSeries) + { + newData.Add(new Bar + { + Name = dataSerie.Name, + X = dataSerie.X.Select(d => (object)d).ToList(), + Y = dataSerie.Y.Select(d => (object)d).ToList(), + }); + } + data = newData; + + await base.OnInitializedAsync(); + } +} diff --git a/Schaad.Accounting.UI/Components/Pages/Transactions.razor b/Schaad.Accounting.UI/Components/Pages/Transactions.razor index d55d04a..6455607 100644 --- a/Schaad.Accounting.UI/Components/Pages/Transactions.razor +++ b/Schaad.Accounting.UI/Components/Pages/Transactions.razor @@ -1,5 +1,6 @@ @page "/transactions" @rendermode InteractiveServer +@using Schaad.Accounting.UI.Components.Pages.Charts Transaktionen @@ -42,6 +43,18 @@ Neue Transaktion hinzufügen + + @if (selectedAccount.Class == ClassIds.Expenses) + { + + + + + + + + + } @@ -52,7 +65,7 @@ @GetAccountName(context) - @context.GetValue(selectedAccount.IsFxAccount).ToString("N2") @selectedAccount.Currency + @context.GetValue(selectedAccount.IsFxAccount, selectedAccountId).ToString("N2") @selectedAccount.Currency