AccountingNext/Schaad.Accounting.Tests/ViewServiceTestShould.cs
Claudio Schaad 857b312f9f PR K: hide IFxService and FixerIo key behind IFxConverter
New IFxConverter.ConvertToChf(amount, fromCurrency) and FxConverter
wrap the vendor IFxService and the FixerIo API key so callers stop
threading the key through every conversion. ViewService drops
IFxService and ISettingsService from its constructor and takes
IFxConverter instead; GetAccountViewList and GetBalanceSheetView no
longer read settingsService.GetSettings() per method.

FxConverterTestShould locks in the target-currency + API-key routing.
40 tests total, all passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 21:51:44 +02:00

207 lines
8.9 KiB
C#

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<IAccountRepository>();
private readonly ITransactionRepository transactionRepo = Substitute.For<ITransactionRepository>();
private readonly IBankTransactionRepository bankTransactionRepo = Substitute.For<IBankTransactionRepository>();
private readonly IBookingRuleRepository bookingRuleRepo = Substitute.For<IBookingRuleRepository>();
private readonly ISubclassRepository subclassRepo = Substitute.For<ISubclassRepository>();
private readonly IFxConverter fxConverter = Substitute.For<IFxConverter>();
public ViewServiceTestShould()
{
// Cover every subclass number a test might reference (Account.SubClass = Number / 100).
subclassRepo.GetSubClassList().Returns(
Enumerable.Range(10, 50).Select(n => new SubClass { Number = n, Name = "sub-" + n }).ToList());
subclassRepo.GetClass(Arg.Any<int>()).Returns("");
// Passthrough FX by default (CHF-only). Individual tests can override.
fxConverter.ConvertToChf(Arg.Any<decimal>(), Arg.Any<string>())
.Returns(ci => ci.ArgAt<decimal>(0));
}
private ViewService BuildService() =>
new(accountRepo, bankTransactionRepo, transactionRepo, subclassRepo, bookingRuleRepo, fxConverter);
// --- Balance math ---------------------------------------------------------
[Fact]
public void 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.GetAccountList().Returns(new List<Account> { cash, receivables, payables });
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
var view = BuildService().GetBalanceView();
view.ActivaAccountList.Count.ShouldBe(2);
view.PassivaAccountList.Count.ShouldBe(1);
view.TotalActivaCHF.ShouldBe(150m);
view.TotalPassivaCHF.ShouldBe(200m);
}
[Fact]
public void ApplyTransactionsToStartBalanceWhenComputingAccountBalance()
{
var cash = Account("cash", 1000, "CHF", startBalance: 1000m);
var groceries = Account("groc", 4000, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { cash, groceries });
// Debit cash 50, credit groceries 50 (spending)
transactionRepo.GetTransactionList().Returns(new List<Transaction>
{
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 = BuildService().GetAccountViewList();
accounts.Single(a => a.Id == "cash").Balance.ShouldBe(920m); // 1000 - 80
accounts.Single(a => a.Id == "groc").Balance.ShouldBe(80m);
}
[Fact]
public void ConvertBalanceToChfWhenAccountUsesForeignCurrency()
{
var usd = Account("usd", 1050, "USD", startBalance: 100m);
accountRepo.GetAccountList().Returns(new List<Account> { usd });
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
// 1 USD = 0.90 CHF
fxConverter.ConvertToChf(Arg.Any<decimal>(), "USD")
.Returns(ci => ci.ArgAt<decimal>(0) * 0.9m);
var account = BuildService().GetAccountViewList().Single();
account.Balance.ShouldBe(100m); // account currency
account.BalanceCHF.ShouldBe(90m); // FX-converted
account.StartBalanceCHF.ShouldBe(90m);
}
// --- Bank-transaction auto-matching --------------------------------------
[Fact]
public void MatchBankTransactionByBookingRuleWhenLookupTextIsContained()
{
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var utilities = Account("util", 4200, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { checking, utilities });
bookingRuleRepo.GetBookingRuleList().Returns(new List<BookingRule>
{
new() { Id = "r1", LookupText = "electric", AccountId = "util", BookingText = "Strom" }
});
bankTransactionRepo.GetBankTransactionList().Returns(new List<BankTransaction>
{
new() { Id = "b1", Text = "Monthly Electric Bill", Value = -85m, BankAccountNumber = "CH01",
ValueDate = new DateTime(2026, 3, 5), BookingDate = new DateTime(2026, 3, 5) }
});
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
var matched = BuildService().MatchOpenBankTransactions().Single();
matched.Text.ShouldBe("Strom");
// negative value => bank account is origin; rule fills the target
matched.OriginAccountId.ShouldBe("chk");
matched.TargetAccountId.ShouldBe("util");
}
[Fact]
public void PreferBookingRuleWithMatchingValueWhenMultipleRulesMatchText()
{
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var rent = Account("rent", 4100, "CHF");
var groceries = Account("groc", 4200, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { checking, rent, groceries });
bookingRuleRepo.GetBookingRuleList().Returns(new List<BookingRule>
{
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.GetBankTransactionList().Returns(new List<BankTransaction>
{
new() { Id = "b1", Text = "COOP payment", Value = -1200m, BankAccountNumber = "CH01",
ValueDate = new DateTime(2026, 3, 1), BookingDate = new DateTime(2026, 3, 1) }
});
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
var matched = BuildService().MatchOpenBankTransactions().Single();
matched.Text.ShouldBe("Rent via Coop");
matched.TargetAccountId.ShouldBe("rent");
}
[Fact]
public void FallBackToSameAccountsLastMonthWhenNoBookingRuleMatches()
{
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var salary = Account("sal", 3000, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { checking, salary });
bookingRuleRepo.GetBookingRuleList().Returns(new List<BookingRule>());
bankTransactionRepo.GetBankTransactionList().Returns(new List<BankTransaction>
{
new() { Id = "b2", Text = "Salary Feb", Value = 5000m, BankAccountNumber = "CH01",
ValueDate = new DateTime(2026, 2, 25), BookingDate = new DateTime(2026, 2, 25) }
});
// A prior-month booking with same accounts + value should be picked up
transactionRepo.GetTransactionList().Returns(new List<Transaction>
{
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 = BuildService().MatchOpenBankTransactions().Single();
matched.Text.ShouldBe("Monatslohn");
}
[Fact]
public void ExcludeIgnoredAndAlreadyBookedWhenGettingOpenBankTransactions()
{
bankTransactionRepo.GetBankTransactionList().Returns(new List<BankTransaction>
{
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.GetTransactionList().Returns(new List<Transaction>
{
new() { Id = "t1", BankTransactionId = "booked", OriginAccountId = "a", TargetAccountId = "b", Value = 10 }
});
var open = BuildService().GetOpenBankTransactionList();
open.Count.ShouldBe(1);
open[0].Id.ShouldBe("open");
}
// --- Helpers -------------------------------------------------------------
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
};
}