Compare commits

..

3 commits

Author SHA1 Message Date
e6d28a6e85 only for expenses 2026-07-22 14:39:05 +02:00
40105189f5 fix it 2026-07-22 14:37:27 +02:00
d819a836da 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.
2026-07-22 14:33:54 +02:00
6 changed files with 122 additions and 32 deletions

View file

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

View file

@ -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(
@ -131,8 +131,9 @@ namespace Schaad.Accounting.Services
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))))
.Select(g => new CategoryTotal(g.Key, g.Sum(t => t.GetValue(account.IsFxAccount, accountId))))
.Where(c => c.Value != 0)
.ToList();
}

View file

@ -132,8 +132,9 @@ namespace Schaad.Accounting.Services
}
/// <summary>
/// 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 <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>
public async Task<List<TransactionDataset>> 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;
}
}
/// <summary>

View file

@ -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<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]
@ -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<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]

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

@ -44,6 +44,8 @@
</FluentGridItem>
</FluentGrid>
@if (selectedAccount.Class == ClassIds.Expenses)
{
<FluentGrid Justify="JustifyContent.FlexStart">
<FluentGridItem xs="6">
<BookingTextOverTime @key="selectedAccount.Id" AccountId="@selectedAccount.Id"/>
@ -52,6 +54,7 @@
<BookingTextBreakdown @key="selectedAccount.Id" AccountId="@selectedAccount.Id"/>
</FluentGridItem>
</FluentGrid>
}
<FluentPaginator State="@pagination" SummaryTemplate="@template"/>
<FluentDataGrid Items="@transactionList" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
@ -62,7 +65,7 @@
@GetAccountName(context)
</TemplateColumn>
<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 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))"/>