using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using Schaad.Accounting.Interfaces; using Schaad.Accounting.Models; using Schaad.Accounting.Services; using Shouldly; namespace Schaad.Accounting.Tests; public class ViewServiceTestShould { private readonly IAccountRepository accountRepo = Substitute.For(); private readonly ITransactionRepository transactionRepo = Substitute.For(); private readonly IBankTransactionRepository bankTransactionRepo = Substitute.For(); private readonly IBookingRuleRepository bookingRuleRepo = Substitute.For(); private readonly ISubclassRepository subclassRepo = Substitute.For(); private readonly IFxConverter fxConverter = Substitute.For(); public ViewServiceTestShould() { subclassRepo.GetSubClassListAsync().Returns( Enumerable.Range(10, 50).Select(n => new SubClass { Number = n, Name = "sub-" + n }).ToList()); subclassRepo.GetClass(Arg.Any()).Returns(""); fxConverter.ConvertToChf(Arg.Any(), Arg.Any()) .Returns(ci => ci.ArgAt(0)); } private ViewService BuildService() => new(accountRepo, bankTransactionRepo, transactionRepo, subclassRepo, bookingRuleRepo, fxConverter, NullLogger.Instance); // --- Balance math --------------------------------------------------------- [Fact] public async Task SumActivaAndPassivaTotalsSeparatelyWhenGettingBalanceView() { var cash = Account("cash", 1000, "CHF", startBalance: 100m); var receivables = Account("recv", 1100, "CHF", startBalance: 50m); var payables = Account("pay", 2000, "CHF", startBalance: -200m); accountRepo.GetAccountListAsync().Returns(new List { cash, receivables, payables }); transactionRepo.GetTransactionListAsync().Returns(new List()); var view = await BuildService().GetBalanceViewAsync(); view.ActivaAccountList.Count.ShouldBe(2); view.PassivaAccountList.Count.ShouldBe(1); view.TotalActivaCHF.ShouldBe(150m); view.TotalPassivaCHF.ShouldBe(200m); } [Fact] public async Task ApplyTransactionsToStartBalanceWhenComputingAccountBalance() { var cash = Account("cash", 1000, "CHF", startBalance: 1000m); var groceries = Account("groc", 4000, "CHF"); accountRepo.GetAccountListAsync().Returns(new List { cash, groceries }); transactionRepo.GetTransactionListAsync().Returns(new List { new() { Id = "t1", OriginAccountId = "cash", TargetAccountId = "groc", Value = 50m, ValueDate = new DateTime(2026, 1, 1) }, new() { Id = "t2", OriginAccountId = "cash", TargetAccountId = "groc", Value = 30m, ValueDate = new DateTime(2026, 1, 2) }, }); var accounts = await BuildService().GetAccountViewListAsync(); accounts.Single(a => a.Id == "cash").Balance.ShouldBe(920m); accounts.Single(a => a.Id == "groc").Balance.ShouldBe(80m); } [Fact] public async Task ConvertBalanceToChfWhenAccountUsesForeignCurrency() { var usd = Account("usd", 1050, "USD", startBalance: 100m); accountRepo.GetAccountListAsync().Returns(new List { usd }); transactionRepo.GetTransactionListAsync().Returns(new List()); fxConverter.ConvertToChf(Arg.Any(), "USD") .Returns(ci => ci.ArgAt(0) * 0.9m); var account = (await BuildService().GetAccountViewListAsync()).Single(); account.Balance.ShouldBe(100m); account.BalanceCHF.ShouldBe(90m); account.StartBalanceCHF.ShouldBe(90m); } // --- Bank-transaction auto-matching -------------------------------------- [Fact] public async Task MatchBankTransactionByBookingRuleWhenLookupTextIsContained() { var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01"); var utilities = Account("util", 4200, "CHF"); accountRepo.GetAccountListAsync().Returns(new List { checking, utilities }); bookingRuleRepo.GetBookingRuleListAsync().Returns(new List { new() { Id = "r1", LookupText = "electric", AccountId = "util", BookingText = "Strom" } }); bankTransactionRepo.GetBankTransactionListAsync().Returns(new List { new() { Id = "b1", Text = "Monthly Electric Bill", Value = -85m, BankAccountNumber = "CH01", ValueDate = new DateTime(2026, 3, 5), BookingDate = new DateTime(2026, 3, 5) } }); transactionRepo.GetTransactionListAsync().Returns(new List()); var matched = (await BuildService().MatchOpenBankTransactionsAsync()).Single(); matched.Text.ShouldBe("Strom"); matched.OriginAccountId.ShouldBe("chk"); matched.TargetAccountId.ShouldBe("util"); } [Fact] public async Task PreferBookingRuleWithMatchingValueWhenMultipleRulesMatchText() { var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01"); var rent = Account("rent", 4100, "CHF"); var groceries = Account("groc", 4200, "CHF"); accountRepo.GetAccountListAsync().Returns(new List { checking, rent, groceries }); bookingRuleRepo.GetBookingRuleListAsync().Returns(new List { new() { Id = "r1", LookupText = "coop", LookupValue = 0m, AccountId = "groc", BookingText = "Groceries" }, new() { Id = "r2", LookupText = "coop", LookupValue = 1200m, AccountId = "rent", BookingText = "Rent via Coop" } }); bankTransactionRepo.GetBankTransactionListAsync().Returns(new List { new() { Id = "b1", Text = "COOP payment", Value = -1200m, BankAccountNumber = "CH01", ValueDate = new DateTime(2026, 3, 1), BookingDate = new DateTime(2026, 3, 1) } }); transactionRepo.GetTransactionListAsync().Returns(new List()); var matched = (await BuildService().MatchOpenBankTransactionsAsync()).Single(); matched.Text.ShouldBe("Rent via Coop"); matched.TargetAccountId.ShouldBe("rent"); } [Fact] public async Task FallBackToSameAccountsLastMonthWhenNoBookingRuleMatches() { var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01"); var salary = Account("sal", 3000, "CHF"); accountRepo.GetAccountListAsync().Returns(new List { checking, salary }); bookingRuleRepo.GetBookingRuleListAsync().Returns(new List()); bankTransactionRepo.GetBankTransactionListAsync().Returns(new List { new() { Id = "b2", Text = "Salary Feb", Value = 5000m, BankAccountNumber = "CH01", ValueDate = new DateTime(2026, 2, 25), BookingDate = new DateTime(2026, 2, 25) } }); transactionRepo.GetTransactionListAsync().Returns(new List { new() { Id = "t1", OriginAccountId = "sal", TargetAccountId = "chk", Value = 5000m, Text = "Monatslohn", ValueDate = new DateTime(2026, 1, 25), BookingDate = new DateTime(2026, 1, 25) } }); var matched = (await BuildService().MatchOpenBankTransactionsAsync()).Single(); matched.Text.ShouldBe("Monatslohn"); } [Fact] public async Task ExcludeIgnoredAndAlreadyBookedWhenGettingOpenBankTransactions() { bankTransactionRepo.GetBankTransactionListAsync().Returns(new List { new() { Id = "open", Text = "still open", Ignore = false }, new() { Id = "ignored", Text = "ignore me", Ignore = true }, new() { Id = "booked", Text = "already booked", Ignore = false } }); transactionRepo.GetTransactionListAsync().Returns(new List { new() { Id = "t1", BankTransactionId = "booked", OriginAccountId = "a", TargetAccountId = "b", Value = 10 } }); var open = await BuildService().GetOpenBankTransactionListAsync(); open.Count.ShouldBe(1); open[0].Id.ShouldBe("open"); } private static Account Account(string id, int number, string currency, decimal startBalance = 0m, string? bankAccountNumber = null) => new() { Id = id, Number = number, Name = id, Currency = currency, StartBalance = startBalance, BankAccountNumber = bankAccountNumber }; }