AccountingNext/Schaad.Accounting.Tests/ChartServiceTestShould.cs
Claudio Schaad d819a836da 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.
2026-07-22 14:33:54 +02:00

281 lines
11 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 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("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 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]
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();
}
}