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.
317 lines
13 KiB
C#
317 lines
13 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 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()
|
|
{
|
|
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]
|
|
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("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("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<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("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<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("groc");
|
|
|
|
totals.ShouldBeEmpty();
|
|
}
|
|
}
|