Compare commits

...

2 commits

Author SHA1 Message Date
4cc0888627 Fix booking-text totals tests to match intended target-only filter
These tests queried an Activa account (checking) as accountId, but
the totals chart only ever runs for Expense-class accounts, which
are always the target side of a spend booking. With a realistic
expense-account setup, the existing TargetAccountId filter behaves
correctly: it excludes bookings where the account is the origin
(a negative value can't be shown as a pie slice) rather than netting
them against the target-side booking.
2026-08-07 18:29:20 +02:00
5d6d49ed8b Group booking-text charts by first word instead of full text
Bookings like "Narbonne Car" and "Narbonne Food" should roll up into
one series/slice instead of staying separate, so grouping now uses
the text up to the first space via TransactionDataset.GetTextGroup().
2026-08-07 18:23:22 +02:00
4 changed files with 98 additions and 16 deletions

View file

@ -43,5 +43,20 @@ namespace Schaad.Accounting.Datasets
return GetValue(withFxRate);
}
/// <summary>
/// First word of <see cref="Text"/>, used to group related bookings (e.g. "Narbonne Car"
/// and "Narbonne Food" both group under "Narbonne").
/// </summary>
public string GetTextGroup()
{
if (string.IsNullOrEmpty(Text))
{
return Text;
}
var spaceIndex = Text.IndexOf(' ');
return spaceIndex < 0 ? Text : Text[..spaceIndex];
}
}
}

View file

@ -108,7 +108,7 @@ namespace Schaad.Accounting.Services
var maxMonth = transactions.Max(t => t.ValueDate.Month);
var list = new List<DataSerie>();
foreach (var grp in transactions.GroupBy(t => t.Text))
foreach (var grp in transactions.GroupBy(t => t.GetTextGroup()))
{
var groupedByMonth = grp
.GroupBy(t => t.ValueDate.Month)
@ -132,7 +132,7 @@ namespace Schaad.Accounting.Services
return transactions
.Where(t => t.TargetAccountId == accountId)
.GroupBy(t => t.Text)
.GroupBy(t => t.GetTextGroup())
.Select(g => new CategoryTotal(g.Key, g.Sum(t => t.GetValue(account.IsFxAccount, accountId))))
.Where(c => c.Value != 0)
.ToList();

View file

@ -193,6 +193,24 @@ public class ChartServiceTestShould
coop.Y[1].ShouldBe(-20m);
}
[Fact]
public async Task GroupBookingTextsByFirstWordWhenBuildingMonthlySeriesForAccount()
{
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: "Narbonne Car"),
MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 1, 6), text: "Narbonne Food")
});
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
var narbonne = series.Single(s => s.Name == "Narbonne");
narbonne.Y[0].ShouldBe(-70m);
}
[Fact]
public async Task ShowPositiveValueWhenAccountIsTargetOfBookingTextTransaction()
{
@ -230,36 +248,54 @@ public class ChartServiceTestShould
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
viewService.GetAccountViewAsync("groc").Returns(AsDataset(groceries));
viewService.GetTransactionViewListAsync("groc").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"),
MakeTransaction(checking, groceries, value: 30m, valueDate: new DateTime(2026, 2, 3), text: "Migros"),
MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 2, 4), text: "Coop")
});
var totals = await BuildService().GetBookingTextTotalsAsync("chk");
var totals = await BuildService().GetBookingTextTotalsAsync("groc");
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()
public async Task GroupBookingTextsByFirstWordWhenBuildingTotalsForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
viewService.GetAccountViewAsync("groc").Returns(AsDataset(groceries));
viewService.GetTransactionViewListAsync("groc").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Narbonne Car"),
MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 1, 6), text: "Narbonne Food")
});
var totals = await BuildService().GetBookingTextTotalsAsync("groc");
totals.Single().Name.ShouldBe("Narbonne");
totals.Single().Value.ShouldBe(70m);
}
[Fact]
public async Task ExcludeBookingsWhereAccountIsOriginWhenBuildingTotalsForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("groc").Returns(AsDataset(groceries));
viewService.GetTransactionViewListAsync("groc").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");
var totals = await BuildService().GetBookingTextTotalsAsync("groc");
totals.Single().Value.ShouldBe(-30m);
totals.Single().Value.ShouldBe(50m);
}
[Fact]
@ -267,14 +303,14 @@ public class ChartServiceTestShould
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
viewService.GetAccountViewAsync("groc").Returns(AsDataset(groceries));
viewService.GetTransactionViewListAsync("groc").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"),
MakeTransaction(checking, groceries, value: -50m, valueDate: new DateTime(2026, 1, 6), text: "Migros")
});
var totals = await BuildService().GetBookingTextTotalsAsync("chk");
var totals = await BuildService().GetBookingTextTotalsAsync("groc");
totals.ShouldBeEmpty();
}

View file

@ -8,7 +8,7 @@ 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) =>
private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, decimal? fxRate = null, string text = "") =>
new(
new Transaction
{
@ -17,6 +17,7 @@ public class TransactionDatasetTestShould
TargetAccountId = target.Id,
Value = value,
FxRate = fxRate,
Text = text,
ValueDate = DateTime.Now,
BookingDate = DateTime.Now
},
@ -51,4 +52,34 @@ public class TransactionDatasetTestShould
transaction.GetValue(withFxRate: true, accountId: "chk").ShouldBe(-200m);
}
[Fact]
public void ReturnFirstWordWhenTextHasMultipleWords()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(checking, groceries, value: 50m, text: "Narbonne Car");
transaction.GetTextGroup().ShouldBe("Narbonne");
}
[Fact]
public void ReturnWholeTextWhenTextHasNoSpace()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(checking, groceries, value: 50m, text: "Migros");
transaction.GetTextGroup().ShouldBe("Migros");
}
[Fact]
public void ReturnEmptyStringWhenTextIsEmpty()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(checking, groceries, value: 50m, text: "");
transaction.GetTextGroup().ShouldBe("");
}
}