Compare commits

..

No commits in common. "786fafefa56b5cacb359518698366152799ebb34" and "8926fb1e973ba6629717768432a991dc521d72a1" have entirely different histories.

36 changed files with 178 additions and 270 deletions

View file

@ -103,10 +103,3 @@ Analysis and phased plan produced 2026-07-02. See conversation history for full
- **PR F** — Phase 3 item 21: precomputed dictionaries in `ViewService` to eliminate O(N·M) `Single(...)` scans and per-account transaction filtering.
- **Item 19 (async I/O)** — deferred. After PR D, each XML file is loaded at most once per SignalR circuit, and this app is single-user local Blazor Server. Converting every repository/service method to async would touch ~40 files for negligible user-visible benefit and real regression risk. Revisit if the app is ever hosted for multiple concurrent users.
- **PR G** — Phase 4 items 23 + 24: `Schaad.Accounting.Tests` xUnit + NSubstitute + Shouldly project. Adopts the `xxxTestShould.DoThisWhenThat` naming convention with Shouldly assertions (no xUnit `Assert.*`). Covers `ViewService`, `TransactionRepository`, `Formatting`, `RepositoryCache`, `AccountRepository`, `ChartService`, and `FileService.GetTransactionListCsv` — 38 tests locking in the earlier PRs' behavior. Note: originally shipped as three separate commits (initial project, expanded coverage, Shouldly + naming conversion) and later squashed into one commit at the user's request.
- **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 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.
- **PR L** — Phase 2 item 10: drop the hand-rolled `Copy(target)` methods from `Account`, `BookingRule`, `BookingText`, `SubClass`, and `SplitPredefinition`; keep only a `Transaction.Clone()` for defensive-copy needs in `GetTransaction` / `ViewService.WithDisplaySign`. Every `SaveXxx` now uses `FindIndex` → in-place replace (or `Add` for new entries), moving the two "hidden" defaults (`Account.Currency = "CHF"` when unset, `Transaction.BookingDate = ValueDate` when unset) into the corresponding `SaveXxx` method where they belong. Fixes a pre-existing bug: `Transaction.Copy` never copied `RelatedParty`, so update-saves silently dropped it. Added `PreserveRelatedPartyWhenRoundTrippingTransaction` and `DefaultBookingDateToValueDateWhenBookingDateIsUnset` as regression tests. 42 tests total.
- **PR M** — Phase 2 item 18 (final slice): add `ILogger<ViewService>` and log a match-count summary from `MatchOpenBankTransactions` (`"Matched {Matched} of {Total} open bank transactions"`). Completes item 18 — statement imports (PR H), repository save failures (PR J), and match runs now all emit structured logs. Also removes a dead `ISettingsService` injection from `Home.razor.cs` (declared with `[Inject]` but never used anywhere in the file).
- **PR N** — Small polish pass covering three long-noted rough edges from the original analysis: `ClassIds` becomes a `static class` (was instantiable); `Home.razor.cs` loses the redundant `loaded` guard in `OnInitializedAsync` (Blazor already runs that lifecycle hook once per component instance); the hardcoded `mandator = "Claudio Schaad"` in `SettingsService` moves to a `DefaultMandator` field on `SettingsDataset`, plumbed through `appsettings.Development.json`.

View file

@ -1,6 +1,6 @@
namespace Schaad.Accounting
{
public static class ClassIds
public class ClassIds
{
public const int Activa = 1;
public const int Passiva = 2;

View file

@ -5,10 +5,5 @@
public string DataPath { get; set; }
public string FixerIoApiKey { get; set; }
/// <summary>
/// Mandator selected when the app starts. Users can switch via the header dropdown.
/// </summary>
public string DefaultMandator { get; set; } = "Claudio Schaad";
}
}

View file

@ -59,5 +59,19 @@ namespace Schaad.Accounting.Models
{
get { return string.IsNullOrEmpty(Currency) == false && Currency != "CHF"; }
}
/// <summary>
/// Makes a copy
/// </summary>
public void Copy(Account target)
{
target.LastBankBalance = LastBankBalance;
target.StartBalance = StartBalance;
target.BankAccountNumber = BankAccountNumber;
target.Currency = string.IsNullOrEmpty(Currency) ? "CHF" : Currency;
target.Id = Id;
target.Name = Name;
target.Number = Number;
}
}
}

View file

@ -20,5 +20,18 @@ namespace Schaad.Accounting.Models
[Display(Name = "Konto")]
[Required]
public string AccountId { get; set; }
/// <summary>
/// Makes a copy
/// </summary>
public void Copy(BookingRule target)
{
target.LookupText = LookupText;
target.LookupValue = LookupValue;
target.BookingText = BookingText;
target.Id = Id;
target.AccountId = AccountId;
}
}
}

View file

@ -10,5 +10,14 @@ namespace Schaad.Accounting.Models
[Required]
[MinLength(3)]
public string Text { get; set; }
/// <summary>
/// Makes a copy
/// </summary>
public void Copy(BookingText target)
{
target.Id = Id;
target.Text = Text;
}
}
}

View file

@ -9,5 +9,17 @@
public decimal BookingValue { get; set; }
public string AccountId { get; set; }
/// <summary>
/// Makes a copy
/// </summary>
public void Copy(SplitPredefinition target)
{
target.BookingText = BookingText;
target.BookingValue = BookingValue;
target.Id = Id;
target.AccountId = AccountId;
}
}
}

View file

@ -14,5 +14,15 @@ namespace Schaad.Accounting.Models
[Required]
[MinLength(3)]
public string Name { get; set; }
/// <summary>
/// Makes a copy
/// </summary>
public void Copy(SubClass target)
{
target.Id = Id;
target.Name = Name;
target.Number = Number;
}
}
}

View file

@ -74,22 +74,20 @@ namespace Schaad.Accounting.Models
}
/// <summary>
/// Returns a shallow independent copy — used to hand out defensive copies
/// (e.g. from repository reads) without exposing the caller to later mutation.
/// Makes a copy
/// </summary>
public Transaction Clone() => new()
public void Copy(Transaction target)
{
Id = Id,
BankTransactionId = BankTransactionId,
BankTransactionText = BankTransactionText,
RelatedParty = RelatedParty,
OriginAccountId = OriginAccountId,
TargetAccountId = TargetAccountId,
Text = Text,
Value = Value,
ValueDate = ValueDate,
BookingDate = BookingDate,
FxRate = FxRate
};
target.BankTransactionId = BankTransactionId;
target.BankTransactionText = BankTransactionText;
target.Id = Id;
target.OriginAccountId = OriginAccountId;
target.TargetAccountId = TargetAccountId;
target.Text = Text;
target.Value = Value;
target.ValueDate = ValueDate;
target.BookingDate = BookingDate > DateTime.MinValue ? BookingDate : ValueDate;
target.FxRate = FxRate;
}
}
}

View file

@ -4,4 +4,9 @@
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>Schaad.Accounting</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Reference Include="Schaad.Finance.Api">
<HintPath>..\dlls\Schaad.Finance.Api.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View file

@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -12,8 +11,8 @@ namespace Schaad.Accounting.Repositories
{
private readonly string ACCOUNTS = "Accounts.xml";
public AccountRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<AccountRepository> logger)
: base(settingsService, cache, logger)
public AccountRepository(ISettingsService settingsService, RepositoryCache cache)
: base(settingsService, cache)
{
EnsureAccountsFile();
}
@ -28,22 +27,16 @@ namespace Schaad.Accounting.Repositories
/// </summary>
public void SaveAccount(Account account)
{
if (string.IsNullOrEmpty(account.Currency))
{
account.Currency = "CHF";
}
var accounts = GetAccountList();
var idx = accounts.FindIndex(a => a.Id == account.Id);
if (idx >= 0)
{
accounts[idx] = account;
}
else
var existingAccount = accounts.FirstOrDefault(a => a.Id == account.Id);
if (existingAccount == null)
{
existingAccount = new Account();
accounts.Add(existingAccount);
account.Id = Guid.NewGuid().ToString();
accounts.Add(account);
}
account.Copy(existingAccount);
Save(accounts, ACCOUNTS);
}

View file

@ -1,6 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -11,8 +10,8 @@ namespace Schaad.Accounting.Repositories
{
private readonly string BANK_TRANSACTIONS = "BankTransactions.xml";
public BankTransactionRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<BankTransactionRepository> logger)
: base(settingsService, cache, logger)
public BankTransactionRepository(ISettingsService settingsService, RepositoryCache cache)
: base(settingsService, cache)
{
}

View file

@ -5,7 +5,6 @@ using System.IO;
using System.Text;
using System.Xml;
using System.Xml.Serialization;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces;
namespace Schaad.Accounting.Repositories
@ -19,13 +18,11 @@ namespace Schaad.Accounting.Repositories
protected readonly ISettingsService settingsService;
protected readonly RepositoryCache cache;
protected readonly ILogger logger;
protected BaseRepository(ISettingsService settingsService, RepositoryCache cache, ILogger logger)
protected BaseRepository(ISettingsService settingsService, RepositoryCache cache)
{
this.settingsService = settingsService;
this.cache = cache;
this.logger = logger;
}
protected void EnsureFileExists(string fileName)
@ -82,9 +79,8 @@ namespace Schaad.Accounting.Repositories
File.Move(tmpPath, filePath, overwrite: true);
}
catch (Exception ex)
catch
{
logger.LogError(ex, "Failed to save {FilePath}", filePath);
if (File.Exists(tmpPath))
{
try { File.Delete(tmpPath); } catch { /* best effort */ }

View file

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -11,8 +10,8 @@ namespace Schaad.Accounting.Repositories
{
private readonly string BOOKING_RULES = "BookingRules.xml";
public BookingRuleRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<BookingRuleRepository> logger)
: base(settingsService, cache, logger)
public BookingRuleRepository(ISettingsService settingsService, RepositoryCache cache)
: base(settingsService, cache)
{
EnsureFileExists(BOOKING_RULES);
}
@ -28,16 +27,15 @@ namespace Schaad.Accounting.Repositories
public void SaveBookingRule(BookingRule bookingRule)
{
var bookingRules = GetBookingRuleList();
var idx = bookingRules.FindIndex(r => r.Id == bookingRule.Id);
if (idx >= 0)
{
bookingRules[idx] = bookingRule;
}
else
var existingRule = bookingRules.FirstOrDefault(a => a.Id == bookingRule.Id);
if (existingRule == null)
{
existingRule = new BookingRule();
bookingRules.Add(existingRule);
bookingRule.Id = Guid.NewGuid().ToString();
bookingRules.Add(bookingRule);
}
bookingRule.Copy(existingRule);
Save(bookingRules, BOOKING_RULES);
}

View file

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -11,8 +10,8 @@ namespace Schaad.Accounting.Repositories
{
private readonly string BOOKING_TEXTS = "BookingTexts.xml";
public BookingTextRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<BookingTextRepository> logger)
: base(settingsService, cache, logger)
public BookingTextRepository(ISettingsService settingsService, RepositoryCache cache)
: base(settingsService, cache)
{
EnsureFileExists(BOOKING_TEXTS);
}
@ -28,16 +27,15 @@ namespace Schaad.Accounting.Repositories
public void SaveBookingText(BookingText bookingText)
{
var bookingTexts = GetBookingTextList();
var idx = bookingTexts.FindIndex(t => t.Id == bookingText.Id);
if (idx >= 0)
{
bookingTexts[idx] = bookingText;
}
else
var existingText = bookingTexts.FirstOrDefault(a => a.Id == bookingText.Id);
if (existingText == null)
{
existingText = new BookingText();
bookingTexts.Add(existingText);
bookingText.Id = Guid.NewGuid().ToString();
bookingTexts.Add(bookingText);
}
bookingText.Copy(existingText);
Save(bookingTexts, BOOKING_TEXTS);
}

View file

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -11,8 +10,8 @@ namespace Schaad.Accounting.Repositories
{
private readonly string SPLIT_PREDEFINITION = "SplitPredefinitions.xml";
public SplitPredefinitionRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<SplitPredefinitionRepository> logger)
: base(settingsService, cache, logger)
public SplitPredefinitionRepository(ISettingsService settingsService, RepositoryCache cache)
: base(settingsService, cache)
{
EnsureFileExists(SPLIT_PREDEFINITION);
}
@ -28,16 +27,15 @@ namespace Schaad.Accounting.Repositories
public void SaveSplitPredefinition(SplitPredefinition splitPredefinition)
{
var definitions = GetSplitPredefinitionList();
var idx = definitions.FindIndex(d => d.Id == splitPredefinition.Id);
if (idx >= 0)
{
definitions[idx] = splitPredefinition;
}
else
var existingDefinition = definitions.FirstOrDefault(a => a.Id == splitPredefinition.Id);
if (existingDefinition == null)
{
existingDefinition = new SplitPredefinition();
definitions.Add(existingDefinition);
splitPredefinition.Id = Guid.NewGuid().ToString();
definitions.Add(splitPredefinition);
}
splitPredefinition.Copy(existingDefinition);
Save(definitions, SPLIT_PREDEFINITION);
}
}

View file

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -12,8 +11,8 @@ namespace Schaad.Accounting.Repositories
private readonly Dictionary<int, string> classes = new Dictionary<int, string>();
private readonly string SUBCLASSES = "SubClasses.xml";
public SubclassRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<SubclassRepository> logger)
: base(settingsService, cache, logger)
public SubclassRepository(ISettingsService settingsService, RepositoryCache cache)
: base(settingsService, cache)
{
EnsureFileExists(SUBCLASSES);
@ -29,21 +28,20 @@ namespace Schaad.Accounting.Repositories
public List<SubClass> GetSubClassList() => LoadList<SubClass>(SUBCLASSES);
/// <summary>
/// Save a subclass (insert/update)
/// Save a booking text (insert/update)
/// </summary>
public void SaveSubClass(SubClass subClass)
{
var subclasses = GetSubClassList();
var idx = subclasses.FindIndex(s => s.Id == subClass.Id);
if (idx >= 0)
{
subclasses[idx] = subClass;
}
else
var existingSubClass = subclasses.FirstOrDefault(a => a.Id == subClass.Id);
if (existingSubClass == null)
{
existingSubClass = new SubClass();
subclasses.Add(existingSubClass);
subClass.Id = Guid.NewGuid().ToString();
subclasses.Add(subClass);
}
subClass.Copy(existingSubClass);
Save(subclasses, SUBCLASSES);
}

View file

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -12,8 +11,8 @@ namespace Schaad.Accounting.Repositories
private readonly IAccountRepository accountRepository;
private readonly string TRANSACTIONS = "Transactions.xml";
public TransactionRepository(ISettingsService settingsService, RepositoryCache cache, IAccountRepository accountRepository, ILogger<TransactionRepository> logger)
: base(settingsService, cache, logger)
public TransactionRepository(ISettingsService settingsService, RepositoryCache cache, IAccountRepository accountRepository)
: base(settingsService, cache)
{
this.accountRepository = accountRepository;
}
@ -45,22 +44,17 @@ namespace Schaad.Accounting.Repositories
transaction.FxRate = null;
}
if (transaction.BookingDate <= DateTime.MinValue)
{
transaction.BookingDate = transaction.ValueDate;
}
var transactionList = GetTransactionList();
var idx = transactionList.FindIndex(t => t.Id == transaction.Id);
if (idx >= 0)
{
transactionList[idx] = transaction;
}
else
var existingTransaction = transactionList.FirstOrDefault(a => a.Id == transaction.Id);
if (existingTransaction == null)
{
existingTransaction = new Transaction();
transactionList.Add(existingTransaction);
transaction.Id = Guid.NewGuid().ToString();
transactionList.Add(transaction);
}
transaction.Copy(existingTransaction);
Save(transactionList, TRANSACTIONS);
}
@ -76,7 +70,8 @@ namespace Schaad.Accounting.Repositories
return null;
}
var result = stored.Clone();
var result = new Transaction();
stored.Copy(result);
// value is stored in CHF -> convert back to foreign currency for display/editing
var isFxAccount = accountRepository.GetAccount(result.OriginAccountId).IsFxAccount

View file

@ -7,8 +7,4 @@
<ItemGroup>
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
</ItemGroup>
</Project>

View file

@ -2,7 +2,6 @@ using System.Collections.Generic;
using System.IO.Compression;
using System.Linq;
using System.Text;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces;
using Schaad.Finance.Api;
@ -18,7 +17,6 @@ namespace Schaad.Accounting.Services
private readonly ISettingsService settingsService;
private readonly IAccountStatementService accountStatementService;
private readonly ICreditCardStatementService creditCardStatementService;
private readonly ILogger<FileService> logger;
public FileService(
ISettingsService settingsService,
@ -26,8 +24,7 @@ namespace Schaad.Accounting.Services
ITransactionRepository transactionsRepository,
IBankTransactionRepository bankTransactionRepository,
IAccountStatementService accountStatementService,
ICreditCardStatementService creditCardStatementService,
ILogger<FileService> logger)
ICreditCardStatementService creditCardStatementService)
{
this.settingsService = settingsService;
this.accountRepository = accountRepository;
@ -35,7 +32,6 @@ namespace Schaad.Accounting.Services
this.bankTransactionRepository = bankTransactionRepository;
this.accountStatementService = accountStatementService;
this.creditCardStatementService = creditCardStatementService;
this.logger = logger;
}
public string Backup()
@ -50,8 +46,6 @@ namespace Schaad.Accounting.Services
// http://www.mikesdotnetting.com/article/288/asp-net-5-uploading-files-with-asp-net-mvc-6
public IReadOnlyList<MessageDataset> ImportAccountStatementFile(string filePath)
{
logger.LogInformation("Importing account statement file {FilePath}", filePath);
var messages = new List<MessageDataset>();
var accountStatementResults = accountStatementService.ReadFile(filePath, Encoding.UTF8);
@ -62,7 +56,6 @@ namespace Schaad.Accounting.Services
if (accountStatementResult.IsSuccess == false)
{
logger.LogError("Statement parse failed for account {AccountNumber}: {Error}", account.AccountNumber, accountStatementResult.Error);
messages.Add(new MessageDataset($"Account {account.AccountNumber} NICHT importiert: {accountStatementResult.Error}", MessageStatus.Error));
}
@ -94,9 +87,6 @@ namespace Schaad.Accounting.Services
var count = bankTransactionRepository.SaveBankTransactionList(account.AccountNumber, transactionsThisYear);
accountRepository.SaveBankAccountBalance(account.AccountNumber, (decimal)account.EndBalance.Value);
logger.LogInformation("Imported {Imported} of {Total} transactions for account {AccountNumber}",
count, account.Transactions.Count, account.AccountNumber);
var status = count == account.Transactions.Count ? MessageStatus.Success : MessageStatus.Info;
message.Add($"{count} von {account.Transactions.Count} Transaktion(en) importiert.", status);
@ -109,8 +99,6 @@ namespace Schaad.Accounting.Services
}
else
{
logger.LogWarning("Skipping account {AccountNumber}: not part of the currently selected mandator ({Skipped} transactions ignored)",
account.AccountNumber, account.Transactions.Count);
message.Add($"Falscher Mandant: {account.Transactions.Count} Transaktion(en) nicht importiert.", MessageStatus.Warning);
}
}

View file

@ -1,21 +0,0 @@
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);
}
}

View file

@ -1,11 +0,0 @@
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);
}
}

View file

@ -6,7 +6,6 @@
<ItemGroup>
<PackageReference Include="FreeSpire.PDF" Version="10.2.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />

View file

@ -10,13 +10,12 @@ namespace Schaad.Accounting.Services
public class SettingsService : ISettingsService
{
private readonly SettingsDataset settingsDataset;
private string mandator;
private string mandator = "Claudio Schaad";
private int year = DateTime.Now.Year;
public SettingsService(SettingsDataset settingsDataset)
{
this.settingsDataset = settingsDataset;
this.mandator = settingsDataset.DefaultMandator;
}
public int GetYear()

View file

@ -1,11 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Datasets.Reports;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Finance.Api;
using Schaad.Finance.Api.Datasets;
namespace Schaad.Accounting.Services
@ -17,8 +17,8 @@ namespace Schaad.Accounting.Services
private readonly IBookingRuleRepository bookingRuleRepository;
private readonly ISubclassRepository subclassRepository;
private readonly ITransactionRepository transactionRepository;
private readonly IFxConverter fxConverter;
private readonly ILogger<ViewService> logger;
private readonly IFxService fxService;
private readonly ISettingsService settingsService;
public ViewService(
IAccountRepository accountRepository,
@ -26,16 +26,16 @@ namespace Schaad.Accounting.Services
ITransactionRepository transactionRepository,
ISubclassRepository subclassRepository,
IBookingRuleRepository bookingRuleRepository,
IFxConverter fxConverter,
ILogger<ViewService> logger)
IFxService fxService,
ISettingsService settingsService)
{
this.accountRepository = accountRepository;
this.bankTransactionRepository = bankTransactionRepository;
this.transactionRepository = transactionRepository;
this.subclassRepository = subclassRepository;
this.bookingRuleRepository = bookingRuleRepository;
this.fxConverter = fxConverter;
this.logger = logger;
this.fxService = fxService;
this.settingsService = settingsService;
}
/// <summary>
@ -55,6 +55,7 @@ namespace Schaad.Accounting.Services
var accounts = accountRepository.GetAccountList();
var transactionList = GetTransactionViewList();
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).
var creditsByAccount = transactionList.GroupBy(t => t.TargetAccountId).ToDictionary(g => g.Key, g => g.ToList());
@ -66,8 +67,8 @@ namespace Schaad.Accounting.Services
return new AccountDataset(
account: a,
balance: balance,
balanceCHF: fxConverter.ConvertToChf(balance, a.Currency),
startBalanceCHF: fxConverter.ConvertToChf(a.StartBalance, a.Currency),
balanceCHF: fxService.ConvertCurrency(balance, a.Currency, "CHF", settings.FixerIoApiKey),
startBalanceCHF: fxService.ConvertCurrency(a.StartBalance, a.Currency, "CHF", settings.FixerIoApiKey),
className: subclassRepository.GetClass(a.Class),
subClassName: subClassNameByNumber[a.SubClass]);
})
@ -110,8 +111,9 @@ namespace Schaad.Accounting.Services
{
var accountList = GetAccountViewList();
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 => fxConverter.ConvertToChf(m.StartBalance, m.Currency)));
var settings = settingsService.GetSettings();
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)));
var loss = Math.Abs(accountList.Where(m => m.Class == ClassIds.Expenses).Sum(m => m.BalanceCHF));
var balanceView = new BalanceSheetDataset(
@ -163,7 +165,8 @@ namespace Schaad.Accounting.Services
var originAccount = accountsById[t.OriginAccountId];
if (originAccount.Class == ClassIds.Activa && accountId == t.OriginAccountId)
{
var copy = t.Clone();
var copy = new Transaction();
t.Copy(copy);
copy.Value *= -1;
return copy;
}
@ -208,7 +211,7 @@ namespace Schaad.Accounting.Services
var transactions = transactionRepository.GetTransactionList();
var accounts = accountRepository.GetAccountList();
var bookingRules = bookingRuleRepository.GetBookingRuleList();
var bankTransactions = GetOpenBankTransactionList().OrderBy(t => t.ValueDate).ToList();
var bankTransactions = GetOpenBankTransactionList().OrderBy(t => t.ValueDate);
foreach (var bankTransaction in bankTransactions)
{
var trx = new Transaction(bankTransaction, accounts);
@ -217,9 +220,6 @@ namespace Schaad.Accounting.Services
MatchBankTransactionBySameValueLastMonth(bankTransaction, trx, transactions);
newTransactionList.Add(trx);
}
var matched = newTransactionList.Count(t => string.IsNullOrEmpty(t.Text) == false);
logger.LogInformation("Matched {Matched} of {Total} open bank transactions", matched, bankTransactions.Count);
return newTransactionList;
}

View file

@ -1,4 +1,3 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -22,7 +21,7 @@ public class AccountRepositoryTestShould : IDisposable
settingsService.GetDbPath().Returns(dbDir);
settingsService.GetLastYearDbPath().Returns(Path.Combine(dbDir, "no-such-dir"));
sut = new AccountRepository(settingsService, new RepositoryCache(), NullLogger<AccountRepository>.Instance);
sut = new AccountRepository(settingsService, new RepositoryCache());
}
public void Dispose()
@ -104,7 +103,7 @@ public class AccountRepositoryTestShould : IDisposable
sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
// Re-construct with the same directory: existing file, no year rollover, no data loss.
var fresh = new AccountRepository(settingsService, new RepositoryCache(), NullLogger<AccountRepository>.Instance);
var fresh = new AccountRepository(settingsService, new RepositoryCache());
var accounts = fresh.GetAccountList();
accounts.Count.ShouldBe(1);

View file

@ -1,5 +1,4 @@
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -27,7 +26,7 @@ public class FileServiceTestShould
private FileService BuildService() => new(
settingsService, accountRepo, transactionRepo, bankTransactionRepo,
accountStatementService, creditCardStatementService, NullLogger<FileService>.Instance);
accountStatementService, creditCardStatementService);
[Fact]
public void EmitHeaderRowAndRunningBalanceWhenExportingTransactionsCsv()

View file

@ -1,34 +0,0 @@
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);
}
}

View file

@ -1,4 +1,3 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -26,7 +25,7 @@ public class TransactionRepositoryTestShould : IDisposable
accountRepo = Substitute.For<IAccountRepository>();
cache = new RepositoryCache();
sut = new TransactionRepository(settingsService, cache, accountRepo, NullLogger<TransactionRepository>.Instance);
sut = new TransactionRepository(settingsService, cache, accountRepo);
}
public void Dispose()
@ -136,43 +135,4 @@ public class TransactionRepositoryTestShould : IDisposable
sut.GetTransactionList().ShouldBeEmpty();
}
[Fact]
public void PreserveRelatedPartyWhenRoundTrippingTransaction()
{
accountRepo.GetAccount(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
sut.SaveTransaction(new Transaction
{
OriginAccountId = "a",
TargetAccountId = "b",
Value = 10m,
Text = "Rent",
RelatedParty = "ACME Property AG",
ValueDate = new DateTime(2026, 4, 1),
BookingDate = new DateTime(2026, 4, 1)
});
// Regression: earlier Transaction.Copy dropped RelatedParty on every save,
// so the round-trip lost it.
sut.GetTransactionList().Single().RelatedParty.ShouldBe("ACME Property AG");
}
[Fact]
public void DefaultBookingDateToValueDateWhenBookingDateIsUnset()
{
accountRepo.GetAccount(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
sut.SaveTransaction(new Transaction
{
OriginAccountId = "a",
TargetAccountId = "b",
Value = 10m,
Text = "x",
ValueDate = new DateTime(2026, 4, 15)
// BookingDate left at default (DateTime.MinValue)
});
sut.GetTransactionList().Single().BookingDate.ShouldBe(new DateTime(2026, 4, 15));
}
}

View file

@ -1,8 +1,9 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.Services;
using Schaad.Finance.Api;
using Shouldly;
namespace Schaad.Accounting.Tests;
@ -14,7 +15,8 @@ public class ViewServiceTestShould
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>();
private readonly IFxService fxService = Substitute.For<IFxService>();
private readonly ISettingsService settingsService = Substitute.For<ISettingsService>();
public ViewServiceTestShould()
{
@ -22,14 +24,15 @@ public class ViewServiceTestShould
subclassRepo.GetSubClassList().Returns(
Enumerable.Range(10, 50).Select(n => new SubClass { Number = n, Name = "sub-" + n }).ToList());
subclassRepo.GetClass(Arg.Any<int>()).Returns("");
settingsService.GetSettings().Returns(new SettingsDataset { DataPath = "", FixerIoApiKey = "" });
// Passthrough FX by default (CHF-only). Individual tests can override.
fxConverter.ConvertToChf(Arg.Any<decimal>(), Arg.Any<string>())
fxService.ConvertCurrency(Arg.Any<decimal>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(ci => ci.ArgAt<decimal>(0));
}
private ViewService BuildService() =>
new(accountRepo, bankTransactionRepo, transactionRepo, subclassRepo, bookingRuleRepo, fxConverter, NullLogger<ViewService>.Instance);
new(accountRepo, bankTransactionRepo, transactionRepo, subclassRepo, bookingRuleRepo, fxService, settingsService);
// --- Balance math ---------------------------------------------------------
@ -79,7 +82,7 @@ public class ViewServiceTestShould
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
// 1 USD = 0.90 CHF
fxConverter.ConvertToChf(Arg.Any<decimal>(), "USD")
fxService.ConvertCurrency(Arg.Any<decimal>(), "USD", "CHF", Arg.Any<string>())
.Returns(ci => ci.ArgAt<decimal>(0) * 0.9m);
var account = BuildService().GetAccountViewList().Single();

View file

@ -21,7 +21,10 @@ public partial class Home : ComponentBase
[Inject]
private IDialogService dialogService { get; set; } = null!;
[Inject]
private ISettingsService settingsService { get; set; } = null!;
[Inject]
private IFileService fileService { get; set; } = null!;
@ -37,19 +40,25 @@ public partial class Home : ComponentBase
private List<Transaction>? matchingBankTransactions;
private IReadOnlyList<Account> accounts = [];
private IReadOnlyList<string> bookingTexts = [];
FluentInputFile? myFileByBuffer;
int? progressPercent;
string? progressTitle;
bool IsCanceled;
Dictionary<int, string> Files = new();
private bool loaded;
protected override Task OnInitializedAsync()
{
matchingBankTransactions = viewService.MatchOpenBankTransactions();
accounts = viewService.GetAccountViewList();
bookingTexts = bookingTextRepository.GetBookingTextList().Select(b => b.Text).ToArray();
return base.OnInitializedAsync();
if (loaded == false) {
matchingBankTransactions = viewService.MatchOpenBankTransactions();
accounts = viewService.GetAccountViewList();
bookingTexts = bookingTextRepository.GetBookingTextList().Select(b => b.Text).ToArray();
loaded = true;
}
return base.OnInitializedAsync();
}
async Task OnProgressChangeAsync(FluentInputFileEventArgs file)

View file

@ -31,7 +31,6 @@ namespace Schaad.Accounting.UI
services.AddScoped<IAccountStatementService, AccountStatementService>();
services.AddScoped<ICreditCardStatementService, CreditCardStatementService>();
services.AddSingleton<IFxService, DummyFxService>();
services.AddScoped<IFxConverter, FxConverter>();
services.AddSingleton<IPdfParsingService, PdfParsingService>();
return services;

View file

@ -7,7 +7,6 @@
},
"Settings": {
"DataPath": "D:\\Developer\\AccountingData\\",
"FixerIoApiKey": "ee23142f1120bb1223b0e76f98abfa97",
"DefaultMandator": "Claudio Schaad"
"FixerIoApiKey": "ee23142f1120bb1223b0e76f98abfa97"
}
}