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:
parent
1dcccf89b1
commit
d819a836da
6 changed files with 109 additions and 23 deletions
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
54
Schaad.Accounting.Tests/TransactionDatasetTestShould.cs
Normal file
54
Schaad.Accounting.Tests/TransactionDatasetTestShould.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))"/>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue