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.
This commit is contained in:
Claudio Schaad 2026-07-22 14:33:54 +02:00
parent 1dcccf89b1
commit d819a836da
6 changed files with 109 additions and 23 deletions

View file

@ -33,5 +33,15 @@ namespace Schaad.Accounting.Datasets
else else
return Value; return Value;
} }
public decimal GetValue(bool withFxRate, string accountId)
{
if (accountId == OriginAccountId)
{
return -1 * GetValue(withFxRate);
}
return GetValue(withFxRate);
}
} }
} }

View file

@ -112,7 +112,7 @@ namespace Schaad.Accounting.Services
{ {
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.GetValue(account.IsFxAccount))); .ToDictionary(g => g.Key, g => g.Sum(t => t.GetValue(account.IsFxAccount, accountId)));
EnsureEntryForEveryMonth(groupedByMonth, maxMonth); EnsureEntryForEveryMonth(groupedByMonth, maxMonth);
list.Add(new DataSerie( list.Add(new DataSerie(
@ -132,7 +132,7 @@ namespace Schaad.Accounting.Services
return transactions return transactions
.GroupBy(t => t.Text) .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) .Where(c => c.Value != 0)
.ToList(); .ToList();
} }

View file

@ -132,8 +132,9 @@ namespace Schaad.Accounting.Services
} }
/// <summary> /// <summary>
/// Get transaction list filtered to a single account, with values sign-flipped /// Get transaction list filtered to a single account. Sign is not baked in here -
/// when the requested account is the origin of an activa booking. /// callers use <see cref="TransactionDataset.GetValue(bool, string)"/> with this
/// account's id to get a value that's negative when the account is paying out.
/// </summary> /// </summary>
public async Task<List<TransactionDataset>> GetTransactionViewListAsync(string accountId) public async Task<List<TransactionDataset>> GetTransactionViewListAsync(string accountId)
{ {
@ -143,22 +144,10 @@ namespace Schaad.Accounting.Services
return transactionList.Select(t => return transactionList.Select(t =>
new TransactionDataset( new TransactionDataset(
WithDisplaySign(t), t,
accountsById[t.OriginAccountId], accountsById[t.OriginAccountId],
accountsById[t.TargetAccountId])) accountsById[t.TargetAccountId]))
.ToList(); .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;
}
} }
/// <summary> /// <summary>

View file

@ -186,11 +186,27 @@ public class ChartServiceTestShould
series.Count.ShouldBe(2); series.Count.ShouldBe(2);
var migros = series.Single(s => s.Name == "Migros"); var migros = series.Single(s => s.Name == "Migros");
migros.Y[0].ShouldBe(50m); migros.Y[0].ShouldBe(-50m);
migros.Y[1].ShouldBe(0m); migros.Y[1].ShouldBe(0m);
var coop = series.Single(s => s.Name == "Coop"); var coop = series.Single(s => s.Name == "Coop");
coop.Y[0].ShouldBe(0m); 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<TransactionDataset>
{
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] [Fact]
@ -206,7 +222,7 @@ public class ChartServiceTestShould
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk"); var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
series.Single().Y.Single().ShouldBe(200m); series.Single().Y.Single().ShouldBe(-200m);
} }
[Fact] [Fact]
@ -225,8 +241,25 @@ public class ChartServiceTestShould
var totals = await BuildService().GetBookingTextTotalsAsync("chk"); var totals = await BuildService().GetBookingTextTotalsAsync("chk");
totals.Count.ShouldBe(2); totals.Count.ShouldBe(2);
totals.ShouldContain(t => t.Name == "Migros" && t.Value == 80m); totals.ShouldContain(t => t.Name == "Migros" && t.Value == -80m);
totals.ShouldContain(t => t.Name == "Coop" && t.Value == 20m); 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<TransactionDataset>
{
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] [Fact]

View file

@ -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);
}
}

View file

@ -62,7 +62,7 @@
@GetAccountName(context) @GetAccountName(context)
</TemplateColumn> </TemplateColumn>
<TemplateColumn Title="Betrag" Align="Align.End"> <TemplateColumn Title="Betrag" Align="Align.End">
@context.GetValue(selectedAccount.IsFxAccount).ToString("N2") @selectedAccount.Currency @context.GetValue(selectedAccount.IsFxAccount, selectedAccountId).ToString("N2") @selectedAccount.Currency
</TemplateColumn> </TemplateColumn>
<TemplateColumn Align="Align.Center" Width="50px"> <TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" title="Löschen" aria-label="Löschen" OnClick="@(() => DeleteAsync(context.Id))"/> <FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" title="Löschen" aria-label="Löschen" OnClick="@(() => DeleteAsync(context.Id))"/>