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(); private readonly IViewService viewService = Substitute.For(); 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 { AsDataset(groceries), AsDataset(checking) }); viewService.GetTransactionViewListAsync().Returns(new List { 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()); settingsService.DidNotReceive().TrySetYear(Arg.Any()); } [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 { AsDataset(mannenbach), AsDataset(mannenbach2), AsDataset(checking) }); viewService.GetTransactionViewListAsync().Returns(new List { 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 { AsDataset(groceries, subClassName: "Groceries"), AsDataset(utilities, subClassName: "Utilities"), AsDataset(checking) }); viewService.GetTransactionViewListAsync().Returns(new List { 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 { AsDataset(groceries), AsDataset(utilities), AsDataset(checking) }); viewService.GetTransactionViewListAsync().Returns(new List { 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 { AsDataset(usSubscription), AsDataset(checking) }); viewService.GetTransactionViewListAsync().Returns(new List { 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 { AsDataset(usSubscription), AsDataset(utilities), AsDataset(checking) }); viewService.GetTransactionViewListAsync().Returns(new List { 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 { 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 GroupBookingTextsByFirstWordWhenBuildingMonthlySeriesForAccount() { var checking = MakeAccount("chk", 1010); var groceries = MakeAccount("groc", 4200); viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking)); viewService.GetTransactionViewListAsync("chk").Returns(new List { 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() { var checking = MakeAccount("chk", 1010); var salary = MakeAccount("sal", 3000); viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking)); viewService.GetTransactionViewListAsync("chk").Returns(new List { 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] 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 { 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("groc").Returns(AsDataset(groceries)); viewService.GetTransactionViewListAsync("groc").Returns(new List { 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("groc"); 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 GroupBookingTextsByFirstWordWhenBuildingTotalsForAccount() { var checking = MakeAccount("chk", 1010); var groceries = MakeAccount("groc", 4200); viewService.GetAccountViewAsync("groc").Returns(AsDataset(groceries)); viewService.GetTransactionViewListAsync("groc").Returns(new List { 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 { 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("groc"); totals.Single().Value.ShouldBe(50m); } [Fact] public async Task ExcludeZeroNetBookingTextsWhenBuildingTotalsForAccount() { var checking = MakeAccount("chk", 1010); var groceries = MakeAccount("groc", 4200); viewService.GetAccountViewAsync("groc").Returns(AsDataset(groceries)); viewService.GetTransactionViewListAsync("groc").Returns(new List { 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("groc"); totals.ShouldBeEmpty(); } }