From d819a836da685ae37e953fe1aa93639700a06e0e Mon Sep 17 00:00:00 2001 From: Claudio Schaad Date: Wed, 22 Jul 2026 14:33:54 +0200 Subject: [PATCH] Show signed amounts on the transactions page and in its charts Adds TransactionDataset.GetValue(bool, string accountId), which negates the value when the given account is the transaction's origin (paying out) so the account's own transaction list and charts show a minus sign for outgoing money, regardless of account class. Removes the old Activa-only sign flip baked into ViewService.GetTransactionViewListAsync(accountId), which would have double-negated Activa bookings against the new accountId-aware GetValue. ChartService's booking-text totals now sum through the same accountId-aware GetValue instead of filtering to inbound transactions only, so a booking text nets its in/out transactions correctly. --- .../Datasets/TransactionDataset.cs | 10 ++++ Schaad.Accounting.Services/ChartService.cs | 4 +- Schaad.Accounting.Services/ViewService.cs | 19 ++----- .../ChartServiceTestShould.cs | 43 +++++++++++++-- .../TransactionDatasetTestShould.cs | 54 +++++++++++++++++++ .../Components/Pages/Transactions.razor | 2 +- 6 files changed, 109 insertions(+), 23 deletions(-) create mode 100644 Schaad.Accounting.Tests/TransactionDatasetTestShould.cs 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 03cf130..103ad09 100644 --- a/Schaad.Accounting.Services/ChartService.cs +++ b/Schaad.Accounting.Services/ChartService.cs @@ -112,7 +112,7 @@ namespace Schaad.Accounting.Services { var groupedByMonth = grp .GroupBy(t => t.ValueDate.Month) - .ToDictionary(g => g.Key, g => g.Sum(t => t.GetValue(account.IsFxAccount))); + .ToDictionary(g => g.Key, g => g.Sum(t => t.GetValue(account.IsFxAccount, accountId))); EnsureEntryForEveryMonth(groupedByMonth, maxMonth); list.Add(new DataSerie( @@ -132,7 +132,7 @@ namespace Schaad.Accounting.Services return transactions .GroupBy(t => t.Text) - .Select(g => new CategoryTotal(g.Key, g.Sum(t => t.GetValue(account.IsFxAccount)))) + .Select(g => new CategoryTotal(g.Key, g.Sum(t => t.GetValue(account.IsFxAccount, accountId)))) .Where(c => c.Value != 0) .ToList(); } 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 5e00978..1e6dcb9 100644 --- a/Schaad.Accounting.Tests/ChartServiceTestShould.cs +++ b/Schaad.Accounting.Tests/ChartServiceTestShould.cs @@ -186,11 +186,27 @@ public class ChartServiceTestShould series.Count.ShouldBe(2); var migros = series.Single(s => s.Name == "Migros"); - migros.Y[0].ShouldBe(50m); + 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); + 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] @@ -206,7 +222,7 @@ public class ChartServiceTestShould var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk"); - series.Single().Y.Single().ShouldBe(200m); + series.Single().Y.Single().ShouldBe(-200m); } [Fact] @@ -225,8 +241,25 @@ public class ChartServiceTestShould 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); + 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] 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/Transactions.razor b/Schaad.Accounting.UI/Components/Pages/Transactions.razor index 0d168ff..c26c95e 100644 --- a/Schaad.Accounting.UI/Components/Pages/Transactions.razor +++ b/Schaad.Accounting.UI/Components/Pages/Transactions.razor @@ -62,7 +62,7 @@ @GetAccountName(context) - @context.GetValue(selectedAccount.IsFxAccount).ToString("N2") @selectedAccount.Currency + @context.GetValue(selectedAccount.IsFxAccount, selectedAccountId).ToString("N2") @selectedAccount.Currency