From a19bdf4558bcbbb5cedfbff0a949be3c19ae528e Mon Sep 17 00:00:00 2001 From: Claudio Schaad Date: Wed, 22 Jul 2026 11:03:19 +0200 Subject: [PATCH] Fix SpendingsOverTime chart: apply FX conversion and use subclass names ChartService summed raw transaction Value instead of converting via GetValue(account.IsFxAccount), so FX expense accounts charted in their foreign currency labeled as CHF. Subclass series also took their Name from an arbitrary account in the group instead of the actual subclass name. --- Schaad.Accounting.Services/ChartService.cs | 14 +++-- .../ChartServiceTestShould.cs | 55 ++++++++++++++++--- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/Schaad.Accounting.Services/ChartService.cs b/Schaad.Accounting.Services/ChartService.cs index ecf51e9..6a72fb0 100644 --- a/Schaad.Accounting.Services/ChartService.cs +++ b/Schaad.Accounting.Services/ChartService.cs @@ -31,28 +31,32 @@ namespace Schaad.Accounting.Services .Count(); return distinctSubClasses > 1 - ? GetSubClassExpensesPerMonth(expenseTransactions) + ? await GetSubClassExpensesPerMonthAsync(expenseTransactions) : await GetAccountExpensesPerMonthAsync(); } - private List GetSubClassExpensesPerMonth(List expenseTransactions) + private async Task> GetSubClassExpensesPerMonthAsync(List expenseTransactions) { var year = settingsService.GetYear(); var maxMonth = expenseTransactions .OrderByDescending(t => t.ValueDate) .FirstOrDefault()?.ValueDate.Month ?? 12; + var subClassNameByNumber = (await viewService.GetAccountViewListAsync()) + .GroupBy(a => a.SubClass) + .ToDictionary(g => g.Key, g => g.First().SubClassName); + var list = new List(); foreach (var grp in expenseTransactions.GroupBy(t => t.TargetAccount.SubClass)) { var groupedByMonth = grp .GroupBy(t => t.ValueDate.Month) - .ToDictionary(g => g.Key, g => g.Sum(t => t.Value)); + .ToDictionary(g => g.Key, g => g.Sum(t => t.GetValue(t.TargetAccount.IsFxAccount))); EnsureEntryForEveryMonth(groupedByMonth, maxMonth); list.Add(new DataSerie( Id: grp.Key.ToString(), - Name: grp.First().TargetAccount.Name, + Name: subClassNameByNumber[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() )); @@ -78,7 +82,7 @@ namespace Schaad.Accounting.Services var maxMonth = transactions.Max(t => t.ValueDate.Month); var groupedByMonth = transactions .GroupBy(t => t.ValueDate.Month) - .ToDictionary(g => g.Key, g => g.Sum(t => t.Value)); + .ToDictionary(g => g.Key, g => g.Sum(t => t.GetValue(account.IsFxAccount))); EnsureEntryForEveryMonth(groupedByMonth, maxMonth); list.Add(new DataSerie( diff --git a/Schaad.Accounting.Tests/ChartServiceTestShould.cs b/Schaad.Accounting.Tests/ChartServiceTestShould.cs index 1f1023f..decedb4 100644 --- a/Schaad.Accounting.Tests/ChartServiceTestShould.cs +++ b/Schaad.Accounting.Tests/ChartServiceTestShould.cs @@ -1,3 +1,4 @@ +using System.Linq; using NSubstitute; using Schaad.Accounting.Datasets; using Schaad.Accounting.Interfaces; @@ -70,7 +71,7 @@ public class ChartServiceTestShould var checking = MakeAccount("chk", 1010); viewService.GetAccountViewListAsync().Returns(new List { - AsDataset(groceries), AsDataset(utilities), AsDataset(checking) + AsDataset(groceries, subClassName: "Groceries"), AsDataset(utilities, subClassName: "Utilities"), AsDataset(checking) }); viewService.GetTransactionViewListAsync().Returns(new List { @@ -81,8 +82,8 @@ public class ChartServiceTestShould var series = await BuildService().GetExpensesPerMonthAsync(); series.Count.ShouldBe(2); - series.ShouldContain(s => s.Id == "42"); - series.ShouldContain(s => s.Id == "43"); + series.ShouldContain(s => s.Id == "42" && s.Name == "Groceries"); + series.ShouldContain(s => s.Id == "43" && s.Name == "Utilities"); } [Fact] @@ -106,18 +107,18 @@ public class ChartServiceTestShould series[0].Id.ShouldBe("groc"); } - private static Account MakeAccount(string id, int number) => new() + private static Account MakeAccount(string id, int number, string currency = "CHF") => new() { Id = id, Number = number, Name = id, - Currency = "CHF" + Currency = currency }; - private static AccountDataset AsDataset(Account a) => - new(a, balance: 0, balanceCHF: 0, startBalanceCHF: 0, className: "", subClassName: ""); + 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) => + private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, DateTime valueDate, decimal? fxRate = null) => new( new Transaction { @@ -125,8 +126,46 @@ public class ChartServiceTestShould OriginAccountId = origin.Id, TargetAccountId = target.Id, Value = value, + FxRate = fxRate, ValueDate = valueDate, BookingDate = valueDate }, origin, target); + + [Fact] + public async Task ConvertFxAccountValuesWhenBuildingAccountSeries() + { + var usSubscription = MakeAccount("us", 4000, currency: "USD"); + var checking = MakeAccount("chk", 1010); + viewService.GetAccountViewListAsync().Returns(new List { AsDataset(usSubscription), AsDataset(checking) }); + viewService.GetTransactionViewListAsync().Returns(new List + { + MakeTransaction(checking, usSubscription, value: 100m, valueDate: new DateTime(2026, 1, 1), fxRate: 0.5m) + }); + + var series = await BuildService().GetExpensesPerMonthAsync(); + + series.Single().Y.Single().ShouldBe(200m); + } + + [Fact] + public async Task ConvertFxAccountValuesWhenBuildingSubClassSeries() + { + var usSubscription = MakeAccount("us", 4200, currency: "USD"); + var utilities = MakeAccount("util", 4300); + var checking = MakeAccount("chk", 1010); + viewService.GetAccountViewListAsync().Returns(new List + { + AsDataset(usSubscription), AsDataset(utilities), AsDataset(checking) + }); + viewService.GetTransactionViewListAsync().Returns(new List + { + MakeTransaction(checking, usSubscription, value: 100m, valueDate: new DateTime(2026, 1, 1), fxRate: 0.5m), + MakeTransaction(checking, utilities, value: 30m, valueDate: new DateTime(2026, 2, 1)) + }); + + var series = await BuildService().GetExpensesPerMonthAsync(); + + series.Single(s => s.Id == "42").Y[0].ShouldBe(200m); + } }