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.
This commit is contained in:
Claudio Schaad 2026-07-22 11:03:19 +02:00
parent 90660177f2
commit a19bdf4558
2 changed files with 56 additions and 13 deletions

View file

@ -31,28 +31,32 @@ namespace Schaad.Accounting.Services
.Count(); .Count();
return distinctSubClasses > 1 return distinctSubClasses > 1
? GetSubClassExpensesPerMonth(expenseTransactions) ? await GetSubClassExpensesPerMonthAsync(expenseTransactions)
: await GetAccountExpensesPerMonthAsync(); : await GetAccountExpensesPerMonthAsync();
} }
private List<DataSerie> GetSubClassExpensesPerMonth(List<TransactionDataset> expenseTransactions) private async Task<List<DataSerie>> GetSubClassExpensesPerMonthAsync(List<TransactionDataset> expenseTransactions)
{ {
var year = settingsService.GetYear(); var year = settingsService.GetYear();
var maxMonth = expenseTransactions var maxMonth = expenseTransactions
.OrderByDescending(t => t.ValueDate) .OrderByDescending(t => t.ValueDate)
.FirstOrDefault()?.ValueDate.Month ?? 12; .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<DataSerie>(); var list = new List<DataSerie>();
foreach (var grp in expenseTransactions.GroupBy(t => t.TargetAccount.SubClass)) foreach (var grp in expenseTransactions.GroupBy(t => t.TargetAccount.SubClass))
{ {
var groupedByMonth = grp var groupedByMonth = grp
.GroupBy(t => t.ValueDate.Month) .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); EnsureEntryForEveryMonth(groupedByMonth, maxMonth);
list.Add(new DataSerie( list.Add(new DataSerie(
Id: grp.Key.ToString(), 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(), 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() 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 maxMonth = transactions.Max(t => t.ValueDate.Month);
var groupedByMonth = transactions var groupedByMonth = transactions
.GroupBy(t => t.ValueDate.Month) .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); EnsureEntryForEveryMonth(groupedByMonth, maxMonth);
list.Add(new DataSerie( list.Add(new DataSerie(

View file

@ -1,3 +1,4 @@
using System.Linq;
using NSubstitute; using NSubstitute;
using Schaad.Accounting.Datasets; using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
@ -70,7 +71,7 @@ public class ChartServiceTestShould
var checking = MakeAccount("chk", 1010); var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset> viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
{ {
AsDataset(groceries), AsDataset(utilities), AsDataset(checking) AsDataset(groceries, subClassName: "Groceries"), AsDataset(utilities, subClassName: "Utilities"), AsDataset(checking)
}); });
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset> viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{ {
@ -81,8 +82,8 @@ public class ChartServiceTestShould
var series = await BuildService().GetExpensesPerMonthAsync(); var series = await BuildService().GetExpensesPerMonthAsync();
series.Count.ShouldBe(2); series.Count.ShouldBe(2);
series.ShouldContain(s => s.Id == "42"); series.ShouldContain(s => s.Id == "42" && s.Name == "Groceries");
series.ShouldContain(s => s.Id == "43"); series.ShouldContain(s => s.Id == "43" && s.Name == "Utilities");
} }
[Fact] [Fact]
@ -106,18 +107,18 @@ public class ChartServiceTestShould
series[0].Id.ShouldBe("groc"); 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, Id = id,
Number = number, Number = number,
Name = id, Name = id,
Currency = "CHF" Currency = currency
}; };
private static AccountDataset AsDataset(Account a) => private static AccountDataset AsDataset(Account a, string subClassName = "") =>
new(a, balance: 0, balanceCHF: 0, startBalanceCHF: 0, className: "", 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(
new Transaction new Transaction
{ {
@ -125,8 +126,46 @@ public class ChartServiceTestShould
OriginAccountId = origin.Id, OriginAccountId = origin.Id,
TargetAccountId = target.Id, TargetAccountId = target.Id,
Value = value, Value = value,
FxRate = fxRate,
ValueDate = valueDate, ValueDate = valueDate,
BookingDate = valueDate BookingDate = valueDate
}, },
origin, target); origin, target);
[Fact]
public async Task ConvertFxAccountValuesWhenBuildingAccountSeries()
{
var usSubscription = MakeAccount("us", 4000, currency: "USD");
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset> { AsDataset(usSubscription), AsDataset(checking) });
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{
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<AccountDataset>
{
AsDataset(usSubscription), AsDataset(utilities), AsDataset(checking)
});
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{
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);
}
} }