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>
207 lines
8.9 KiB
C#
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
|
|
};
|
|
}
|