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>
This commit is contained in:
parent
3a770af016
commit
857b312f9f
7 changed files with 79 additions and 21 deletions
|
|
@ -106,3 +106,4 @@ Analysis and phased plan produced 2026-07-02. See conversation history for full
|
||||||
- **PR H** — Phase 2 item 18 (first slice): add `ILogger<FileService>` to `FileService.ImportAccountStatementFile` so bank-statement imports emit `Information` for the file being processed and each account's import count, `Warning` when an account is skipped because it belongs to a different mandator, and `Error` when the vendor parser reports a failure. Rest of the logging (BaseRepository save failures, MatchOpenBankTransactions summary) tracked as a follow-up because it requires threading loggers through all seven repositories.
|
- **PR H** — Phase 2 item 18 (first slice): add `ILogger<FileService>` to `FileService.ImportAccountStatementFile` so bank-statement imports emit `Information` for the file being processed and each account's import count, `Warning` when an account is skipped because it belongs to a different mandator, and `Error` when the vendor parser reports a failure. Rest of the logging (BaseRepository save failures, MatchOpenBankTransactions summary) tracked as a follow-up because it requires threading loggers through all seven repositories.
|
||||||
- **PR I** — Phase 2 item 12: move the service interfaces (`IViewService`, `IFileService`, `IChartService`) from `Schaad.Accounting.Common` into `Schaad.Accounting.Services/Interfaces/`. Namespaces are unchanged (`Schaad.Accounting.Interfaces`), so no consumer needs a `using` update. Drops the `Schaad.Finance.Api` `<Reference>` from `Common.csproj` — Common is now vendor-free and matches its documented role as the "shared models, DTOs, interfaces" layer.
|
- **PR I** — Phase 2 item 12: move the service interfaces (`IViewService`, `IFileService`, `IChartService`) from `Schaad.Accounting.Common` into `Schaad.Accounting.Services/Interfaces/`. Namespaces are unchanged (`Schaad.Accounting.Interfaces`), so no consumer needs a `using` update. Drops the `Schaad.Finance.Api` `<Reference>` from `Common.csproj` — Common is now vendor-free and matches its documented role as the "shared models, DTOs, interfaces" layer.
|
||||||
- **PR J** — Phase 2 item 18 (second slice): thread `ILogger<T>` through `BaseRepository` and every concrete repository so `BaseRepository.Save`'s catch block logs the failing file path and exception before rethrowing (previously the exception's origin was silently swallowed and only the stack trace at the callsite survived). Pulls `Microsoft.Extensions.Logging.Abstractions` into the Db project. `TransactionRepositoryTestShould` and `AccountRepositoryTestShould` use `NullLogger<T>.Instance`. Remaining item 18 work (MatchOpenBankTransactions match-count summary in `ViewService`) still open.
|
- **PR J** — Phase 2 item 18 (second slice): thread `ILogger<T>` through `BaseRepository` and every concrete repository so `BaseRepository.Save`'s catch block logs the failing file path and exception before rethrowing (previously the exception's origin was silently swallowed and only the stack trace at the callsite survived). Pulls `Microsoft.Extensions.Logging.Abstractions` into the Db project. `TransactionRepositoryTestShould` and `AccountRepositoryTestShould` use `NullLogger<T>.Instance`. Remaining item 18 work (MatchOpenBankTransactions match-count summary in `ViewService`) still open.
|
||||||
|
- **PR K** — Phase 5 item 27: introduce `IFxConverter.ConvertToChf(amount, fromCurrency)` and its `FxConverter` implementation. The vendor `IFxService` and the FixerIo API key are now hidden inside `FxConverter`; callers stop threading the API key through every method call. `ViewService` drops both `IFxService` and `ISettingsService` from its constructor and takes `IFxConverter` instead. Simplifies `GetAccountViewList` (no more per-call `settingsService.GetSettings()` reads) and `GetBalanceSheetView`. Two-test `FxConverterTestShould` locks in the target-currency and API-key routing.
|
||||||
|
|
|
||||||
21
Schaad.Accounting.Services/FxConverter.cs
Normal file
21
Schaad.Accounting.Services/FxConverter.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
using Schaad.Accounting.Datasets;
|
||||||
|
using Schaad.Accounting.Interfaces;
|
||||||
|
using Schaad.Finance.Api;
|
||||||
|
|
||||||
|
namespace Schaad.Accounting.Services
|
||||||
|
{
|
||||||
|
public class FxConverter : IFxConverter
|
||||||
|
{
|
||||||
|
private readonly IFxService fxService;
|
||||||
|
private readonly SettingsDataset settings;
|
||||||
|
|
||||||
|
public FxConverter(IFxService fxService, SettingsDataset settings)
|
||||||
|
{
|
||||||
|
this.fxService = fxService;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public decimal ConvertToChf(decimal amount, string fromCurrency)
|
||||||
|
=> fxService.ConvertCurrency(amount, fromCurrency, "CHF", settings.FixerIoApiKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
Schaad.Accounting.Services/Interfaces/IFxConverter.cs
Normal file
11
Schaad.Accounting.Services/Interfaces/IFxConverter.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
namespace Schaad.Accounting.Interfaces
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Thin wrapper over the vendor <c>IFxService</c> that hides the FixerIo API key
|
||||||
|
/// and the base currency, so callers just ask "convert this amount to CHF".
|
||||||
|
/// </summary>
|
||||||
|
public interface IFxConverter
|
||||||
|
{
|
||||||
|
decimal ConvertToChf(decimal amount, string fromCurrency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,6 @@ using Schaad.Accounting.Datasets;
|
||||||
using Schaad.Accounting.Datasets.Reports;
|
using Schaad.Accounting.Datasets.Reports;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
using Schaad.Finance.Api;
|
|
||||||
using Schaad.Finance.Api.Datasets;
|
using Schaad.Finance.Api.Datasets;
|
||||||
|
|
||||||
namespace Schaad.Accounting.Services
|
namespace Schaad.Accounting.Services
|
||||||
|
|
@ -17,8 +16,7 @@ namespace Schaad.Accounting.Services
|
||||||
private readonly IBookingRuleRepository bookingRuleRepository;
|
private readonly IBookingRuleRepository bookingRuleRepository;
|
||||||
private readonly ISubclassRepository subclassRepository;
|
private readonly ISubclassRepository subclassRepository;
|
||||||
private readonly ITransactionRepository transactionRepository;
|
private readonly ITransactionRepository transactionRepository;
|
||||||
private readonly IFxService fxService;
|
private readonly IFxConverter fxConverter;
|
||||||
private readonly ISettingsService settingsService;
|
|
||||||
|
|
||||||
public ViewService(
|
public ViewService(
|
||||||
IAccountRepository accountRepository,
|
IAccountRepository accountRepository,
|
||||||
|
|
@ -26,16 +24,14 @@ namespace Schaad.Accounting.Services
|
||||||
ITransactionRepository transactionRepository,
|
ITransactionRepository transactionRepository,
|
||||||
ISubclassRepository subclassRepository,
|
ISubclassRepository subclassRepository,
|
||||||
IBookingRuleRepository bookingRuleRepository,
|
IBookingRuleRepository bookingRuleRepository,
|
||||||
IFxService fxService,
|
IFxConverter fxConverter)
|
||||||
ISettingsService settingsService)
|
|
||||||
{
|
{
|
||||||
this.accountRepository = accountRepository;
|
this.accountRepository = accountRepository;
|
||||||
this.bankTransactionRepository = bankTransactionRepository;
|
this.bankTransactionRepository = bankTransactionRepository;
|
||||||
this.transactionRepository = transactionRepository;
|
this.transactionRepository = transactionRepository;
|
||||||
this.subclassRepository = subclassRepository;
|
this.subclassRepository = subclassRepository;
|
||||||
this.bookingRuleRepository = bookingRuleRepository;
|
this.bookingRuleRepository = bookingRuleRepository;
|
||||||
this.fxService = fxService;
|
this.fxConverter = fxConverter;
|
||||||
this.settingsService = settingsService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -55,7 +51,6 @@ namespace Schaad.Accounting.Services
|
||||||
var accounts = accountRepository.GetAccountList();
|
var accounts = accountRepository.GetAccountList();
|
||||||
var transactionList = GetTransactionViewList();
|
var transactionList = GetTransactionViewList();
|
||||||
var subClassNameByNumber = subclassRepository.GetSubClassList().ToDictionary(s => s.Number, s => s.Name);
|
var subClassNameByNumber = subclassRepository.GetSubClassList().ToDictionary(s => s.Number, s => s.Name);
|
||||||
var settings = settingsService.GetSettings();
|
|
||||||
|
|
||||||
// Group transactions by account so per-account balance is O(k) instead of O(M).
|
// Group transactions by account so per-account balance is O(k) instead of O(M).
|
||||||
var creditsByAccount = transactionList.GroupBy(t => t.TargetAccountId).ToDictionary(g => g.Key, g => g.ToList());
|
var creditsByAccount = transactionList.GroupBy(t => t.TargetAccountId).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
@ -67,8 +62,8 @@ namespace Schaad.Accounting.Services
|
||||||
return new AccountDataset(
|
return new AccountDataset(
|
||||||
account: a,
|
account: a,
|
||||||
balance: balance,
|
balance: balance,
|
||||||
balanceCHF: fxService.ConvertCurrency(balance, a.Currency, "CHF", settings.FixerIoApiKey),
|
balanceCHF: fxConverter.ConvertToChf(balance, a.Currency),
|
||||||
startBalanceCHF: fxService.ConvertCurrency(a.StartBalance, a.Currency, "CHF", settings.FixerIoApiKey),
|
startBalanceCHF: fxConverter.ConvertToChf(a.StartBalance, a.Currency),
|
||||||
className: subclassRepository.GetClass(a.Class),
|
className: subclassRepository.GetClass(a.Class),
|
||||||
subClassName: subClassNameByNumber[a.SubClass]);
|
subClassName: subClassNameByNumber[a.SubClass]);
|
||||||
})
|
})
|
||||||
|
|
@ -111,9 +106,8 @@ namespace Schaad.Accounting.Services
|
||||||
{
|
{
|
||||||
var accountList = GetAccountViewList();
|
var accountList = GetAccountViewList();
|
||||||
|
|
||||||
var settings = settingsService.GetSettings();
|
|
||||||
var profit = Math.Abs(accountList.Where(m => m.Class == ClassIds.Income).Sum(m => m.BalanceCHF));
|
var profit = Math.Abs(accountList.Where(m => m.Class == ClassIds.Income).Sum(m => m.BalanceCHF));
|
||||||
profit += Math.Abs(accountList.Where(m => m.Class == ClassIds.Activa).Sum(m => fxService.ConvertCurrency(m.StartBalance, m.Currency, "CHF", settings.FixerIoApiKey)));
|
profit += Math.Abs(accountList.Where(m => m.Class == ClassIds.Activa).Sum(m => fxConverter.ConvertToChf(m.StartBalance, m.Currency)));
|
||||||
var loss = Math.Abs(accountList.Where(m => m.Class == ClassIds.Expenses).Sum(m => m.BalanceCHF));
|
var loss = Math.Abs(accountList.Where(m => m.Class == ClassIds.Expenses).Sum(m => m.BalanceCHF));
|
||||||
|
|
||||||
var balanceView = new BalanceSheetDataset(
|
var balanceView = new BalanceSheetDataset(
|
||||||
|
|
|
||||||
34
Schaad.Accounting.Tests/FxConverterTestShould.cs
Normal file
34
Schaad.Accounting.Tests/FxConverterTestShould.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
using NSubstitute;
|
||||||
|
using Schaad.Accounting.Datasets;
|
||||||
|
using Schaad.Accounting.Services;
|
||||||
|
using Schaad.Finance.Api;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace Schaad.Accounting.Tests;
|
||||||
|
|
||||||
|
public class FxConverterTestShould
|
||||||
|
{
|
||||||
|
private readonly IFxService fxService = Substitute.For<IFxService>();
|
||||||
|
private readonly SettingsDataset settings = new() { DataPath = "", FixerIoApiKey = "test-key" };
|
||||||
|
|
||||||
|
private FxConverter BuildConverter() => new(fxService, settings);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DelegateToFxServiceWithChfAsTargetAndConfiguredApiKeyWhenConverting()
|
||||||
|
{
|
||||||
|
fxService.ConvertCurrency(100m, "USD", "CHF", "test-key").Returns(90m);
|
||||||
|
|
||||||
|
var result = BuildConverter().ConvertToChf(100m, "USD");
|
||||||
|
|
||||||
|
result.ShouldBe(90m);
|
||||||
|
fxService.Received(1).ConvertCurrency(100m, "USD", "CHF", "test-key");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PassNegativeAmountsThroughWhenConverting()
|
||||||
|
{
|
||||||
|
fxService.ConvertCurrency(-50m, "EUR", "CHF", Arg.Any<string>()).Returns(-48m);
|
||||||
|
|
||||||
|
BuildConverter().ConvertToChf(-50m, "EUR").ShouldBe(-48m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Schaad.Accounting.Datasets;
|
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
using Schaad.Accounting.Services;
|
using Schaad.Accounting.Services;
|
||||||
using Schaad.Finance.Api;
|
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
|
|
||||||
namespace Schaad.Accounting.Tests;
|
namespace Schaad.Accounting.Tests;
|
||||||
|
|
@ -15,8 +13,7 @@ public class ViewServiceTestShould
|
||||||
private readonly IBankTransactionRepository bankTransactionRepo = Substitute.For<IBankTransactionRepository>();
|
private readonly IBankTransactionRepository bankTransactionRepo = Substitute.For<IBankTransactionRepository>();
|
||||||
private readonly IBookingRuleRepository bookingRuleRepo = Substitute.For<IBookingRuleRepository>();
|
private readonly IBookingRuleRepository bookingRuleRepo = Substitute.For<IBookingRuleRepository>();
|
||||||
private readonly ISubclassRepository subclassRepo = Substitute.For<ISubclassRepository>();
|
private readonly ISubclassRepository subclassRepo = Substitute.For<ISubclassRepository>();
|
||||||
private readonly IFxService fxService = Substitute.For<IFxService>();
|
private readonly IFxConverter fxConverter = Substitute.For<IFxConverter>();
|
||||||
private readonly ISettingsService settingsService = Substitute.For<ISettingsService>();
|
|
||||||
|
|
||||||
public ViewServiceTestShould()
|
public ViewServiceTestShould()
|
||||||
{
|
{
|
||||||
|
|
@ -24,15 +21,14 @@ public class ViewServiceTestShould
|
||||||
subclassRepo.GetSubClassList().Returns(
|
subclassRepo.GetSubClassList().Returns(
|
||||||
Enumerable.Range(10, 50).Select(n => new SubClass { Number = n, Name = "sub-" + n }).ToList());
|
Enumerable.Range(10, 50).Select(n => new SubClass { Number = n, Name = "sub-" + n }).ToList());
|
||||||
subclassRepo.GetClass(Arg.Any<int>()).Returns("");
|
subclassRepo.GetClass(Arg.Any<int>()).Returns("");
|
||||||
settingsService.GetSettings().Returns(new SettingsDataset { DataPath = "", FixerIoApiKey = "" });
|
|
||||||
|
|
||||||
// Passthrough FX by default (CHF-only). Individual tests can override.
|
// Passthrough FX by default (CHF-only). Individual tests can override.
|
||||||
fxService.ConvertCurrency(Arg.Any<decimal>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
fxConverter.ConvertToChf(Arg.Any<decimal>(), Arg.Any<string>())
|
||||||
.Returns(ci => ci.ArgAt<decimal>(0));
|
.Returns(ci => ci.ArgAt<decimal>(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
private ViewService BuildService() =>
|
private ViewService BuildService() =>
|
||||||
new(accountRepo, bankTransactionRepo, transactionRepo, subclassRepo, bookingRuleRepo, fxService, settingsService);
|
new(accountRepo, bankTransactionRepo, transactionRepo, subclassRepo, bookingRuleRepo, fxConverter);
|
||||||
|
|
||||||
// --- Balance math ---------------------------------------------------------
|
// --- Balance math ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -82,7 +78,7 @@ public class ViewServiceTestShould
|
||||||
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
|
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
|
||||||
|
|
||||||
// 1 USD = 0.90 CHF
|
// 1 USD = 0.90 CHF
|
||||||
fxService.ConvertCurrency(Arg.Any<decimal>(), "USD", "CHF", Arg.Any<string>())
|
fxConverter.ConvertToChf(Arg.Any<decimal>(), "USD")
|
||||||
.Returns(ci => ci.ArgAt<decimal>(0) * 0.9m);
|
.Returns(ci => ci.ArgAt<decimal>(0) * 0.9m);
|
||||||
|
|
||||||
var account = BuildService().GetAccountViewList().Single();
|
var account = BuildService().GetAccountViewList().Single();
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ namespace Schaad.Accounting.UI
|
||||||
services.AddScoped<IAccountStatementService, AccountStatementService>();
|
services.AddScoped<IAccountStatementService, AccountStatementService>();
|
||||||
services.AddScoped<ICreditCardStatementService, CreditCardStatementService>();
|
services.AddScoped<ICreditCardStatementService, CreditCardStatementService>();
|
||||||
services.AddSingleton<IFxService, DummyFxService>();
|
services.AddSingleton<IFxService, DummyFxService>();
|
||||||
|
services.AddScoped<IFxConverter, FxConverter>();
|
||||||
services.AddSingleton<IPdfParsingService, PdfParsingService>();
|
services.AddSingleton<IPdfParsingService, PdfParsingService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue