Adds two charts to the per-account transactions view: a stacked bar chart of monthly totals and a pie chart of overall totals, both grouped by booking text, so recurring bookings (rent, subscriptions, etc.) are visible at a glance. Chart components are keyed on the account id so switching accounts remounts them instead of reusing a stale Plotly instance.
248 lines
10 KiB
C#
248 lines
10 KiB
C#
using System.Linq;
|
|
using NSubstitute;
|
|
using Schaad.Accounting.Datasets;
|
|
using Schaad.Accounting.Interfaces;
|
|
using Schaad.Accounting.Models;
|
|
using Schaad.Accounting.Services;
|
|
using Shouldly;
|
|
|
|
namespace Schaad.Accounting.Tests;
|
|
|
|
public class ChartServiceTestShould
|
|
{
|
|
private readonly ISettingsService settingsService = Substitute.For<ISettingsService>();
|
|
private readonly IViewService viewService = Substitute.For<IViewService>();
|
|
|
|
public ChartServiceTestShould()
|
|
{
|
|
settingsService.GetYear().Returns(2026);
|
|
}
|
|
|
|
private ChartService BuildService() => new(settingsService, viewService);
|
|
|
|
[Fact]
|
|
public async Task HonourSelectedYearWhenBuildingMonthlySeries()
|
|
{
|
|
settingsService.GetYear().Returns(2023);
|
|
var groceries = MakeAccount("groc", 4200);
|
|
var checking = MakeAccount("chk", 1010);
|
|
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset> { AsDataset(groceries), AsDataset(checking) });
|
|
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
|
|
{
|
|
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2023, 4, 5))
|
|
});
|
|
|
|
var series = await BuildService().GetExpensesPerMonthAsync();
|
|
|
|
series.Count.ShouldBe(1);
|
|
series[0].X.ShouldAllBe(x => ((DateOnly)x).Year == 2023);
|
|
settingsService.DidNotReceive().SetYear(Arg.Any<int>());
|
|
settingsService.DidNotReceive().TrySetYear(Arg.Any<int>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GroupExpensesByAccountWhenAllExpensesShareOneSubClass()
|
|
{
|
|
var mannenbach = MakeAccount("m1", 4000);
|
|
var mannenbach2 = MakeAccount("m2", 4001);
|
|
var checking = MakeAccount("chk", 1010);
|
|
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
|
|
{
|
|
AsDataset(mannenbach), AsDataset(mannenbach2), AsDataset(checking)
|
|
});
|
|
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
|
|
{
|
|
MakeTransaction(checking, mannenbach, value: 10m, valueDate: new DateTime(2026, 1, 1)),
|
|
MakeTransaction(checking, mannenbach2, value: 20m, valueDate: new DateTime(2026, 1, 1))
|
|
});
|
|
|
|
var series = await BuildService().GetExpensesPerMonthAsync();
|
|
|
|
series.Count.ShouldBe(2);
|
|
series.ShouldContain(s => s.Id == "m1");
|
|
series.ShouldContain(s => s.Id == "m2");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GroupExpensesBySubClassWhenMultipleSubClassesAreUsed()
|
|
{
|
|
var groceries = MakeAccount("groc", 4200);
|
|
var utilities = MakeAccount("util", 4300);
|
|
var checking = MakeAccount("chk", 1010);
|
|
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
|
|
{
|
|
AsDataset(groceries, subClassName: "Groceries"), AsDataset(utilities, subClassName: "Utilities"), AsDataset(checking)
|
|
});
|
|
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
|
|
{
|
|
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 1)),
|
|
MakeTransaction(checking, utilities, value: 30m, valueDate: new DateTime(2026, 2, 1))
|
|
});
|
|
|
|
var series = await BuildService().GetExpensesPerMonthAsync();
|
|
|
|
series.Count.ShouldBe(2);
|
|
series.ShouldContain(s => s.Id == "42" && s.Name == "Groceries");
|
|
series.ShouldContain(s => s.Id == "43" && s.Name == "Utilities");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SkipAccountsWithoutTransactionsWhenBuildingSeries()
|
|
{
|
|
var groceries = MakeAccount("groc", 4000);
|
|
var utilities = MakeAccount("util", 4001);
|
|
var checking = MakeAccount("chk", 1010);
|
|
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
|
|
{
|
|
AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
|
|
});
|
|
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
|
|
{
|
|
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 1))
|
|
});
|
|
|
|
var series = await BuildService().GetExpensesPerMonthAsync();
|
|
|
|
series.Count.ShouldBe(1);
|
|
series[0].Id.ShouldBe("groc");
|
|
}
|
|
|
|
private static Account MakeAccount(string id, int number, string currency = "CHF") => new()
|
|
{
|
|
Id = id,
|
|
Number = number,
|
|
Name = id,
|
|
Currency = currency
|
|
};
|
|
|
|
private static AccountDataset AsDataset(Account a, string subClassName = "") =>
|
|
new(a, balance: 0, balanceCHF: 0, startBalanceCHF: 0, className: "", subClassName: subClassName);
|
|
|
|
private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, DateTime valueDate, decimal? fxRate = null, string text = "") =>
|
|
new(
|
|
new Transaction
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
OriginAccountId = origin.Id,
|
|
TargetAccountId = target.Id,
|
|
Value = value,
|
|
FxRate = fxRate,
|
|
Text = text,
|
|
ValueDate = valueDate,
|
|
BookingDate = valueDate
|
|
},
|
|
origin, target);
|
|
|
|
[Fact]
|
|
public async Task ConvertFxAccountValuesWhenBuildingAccountSeries()
|
|
{
|
|
var usSubscription = MakeAccount("us", 4000, currency: "USD");
|
|
var checking = MakeAccount("chk", 1010);
|
|
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset> { AsDataset(usSubscription), AsDataset(checking) });
|
|
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
|
|
{
|
|
MakeTransaction(checking, usSubscription, value: 100m, valueDate: new DateTime(2026, 1, 1), fxRate: 0.5m)
|
|
});
|
|
|
|
var series = await BuildService().GetExpensesPerMonthAsync();
|
|
|
|
series.Single().Y.Single().ShouldBe(200m);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConvertFxAccountValuesWhenBuildingSubClassSeries()
|
|
{
|
|
var usSubscription = MakeAccount("us", 4200, currency: "USD");
|
|
var utilities = MakeAccount("util", 4300);
|
|
var checking = MakeAccount("chk", 1010);
|
|
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
|
|
{
|
|
AsDataset(usSubscription), AsDataset(utilities), AsDataset(checking)
|
|
});
|
|
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
|
|
{
|
|
MakeTransaction(checking, usSubscription, value: 100m, valueDate: new DateTime(2026, 1, 1), fxRate: 0.5m),
|
|
MakeTransaction(checking, utilities, value: 30m, valueDate: new DateTime(2026, 2, 1))
|
|
});
|
|
|
|
var series = await BuildService().GetExpensesPerMonthAsync();
|
|
|
|
series.Single(s => s.Id == "42").Y[0].ShouldBe(200m);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GroupBookingTextExpensesByTextWhenBuildingMonthlySeriesForAccount()
|
|
{
|
|
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(checking, groceries, value: 20m, valueDate: new DateTime(2026, 2, 3), text: "Coop")
|
|
});
|
|
|
|
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
|
|
|
|
series.Count.ShouldBe(2);
|
|
var migros = series.Single(s => s.Name == "Migros");
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConvertFxAccountValuesWhenBuildingBookingTextSeries()
|
|
{
|
|
var usChecking = MakeAccount("chk", 1010, currency: "USD");
|
|
var groceries = MakeAccount("groc", 4200);
|
|
viewService.GetAccountViewAsync("chk").Returns(AsDataset(usChecking));
|
|
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
|
|
{
|
|
MakeTransaction(usChecking, groceries, value: 100m, valueDate: new DateTime(2026, 1, 5), fxRate: 0.5m, text: "Migros")
|
|
});
|
|
|
|
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
|
|
|
|
series.Single().Y.Single().ShouldBe(200m);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SumBookingTextTotalsAcrossMonthsWhenBuildingTotalsForAccount()
|
|
{
|
|
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(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");
|
|
|
|
totals.Count.ShouldBe(2);
|
|
totals.ShouldContain(t => t.Name == "Migros" && t.Value == 80m);
|
|
totals.ShouldContain(t => t.Name == "Coop" && t.Value == 20m);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExcludeZeroNetBookingTextsWhenBuildingTotalsForAccount()
|
|
{
|
|
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(checking, groceries, value: -50m, valueDate: new DateTime(2026, 1, 6), text: "Migros")
|
|
});
|
|
|
|
var totals = await BuildService().GetBookingTextTotalsAsync("chk");
|
|
|
|
totals.ShouldBeEmpty();
|
|
}
|
|
}
|