Compare commits

..

7 commits

Author SHA1 Message Date
786fafefa5 PR N: polish pass — ClassIds static, drop loaded flag, mandator from config
Three long-noted rough edges from the original analysis, each a
one-line touch:

- ClassIds becomes static class (was instantiable).
- Home.razor.cs drops the `loaded` bool guard (Blazor already runs
  OnInitializedAsync exactly once per component instance).
- Move the "Claudio Schaad" mandator default out of SettingsService
  into SettingsDataset.DefaultMandator, plumbed through
  appsettings.Development.json.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 22:07:08 +02:00
1f1575b0af PR M: log MatchOpenBankTransactions summary; drop dead injection
Add ILogger<ViewService> and log the match-count summary at the end
of MatchOpenBankTransactions: "Matched {Matched} of {Total} open bank
transactions". Completes item 18.

Also drop the ISettingsService [Inject] from Home.razor.cs that was
declared but never referenced.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 22:04:12 +02:00
3ffe4578c2 PR L: drop Copy(target) methods in favour of index-based replace
Replace the hand-rolled model.Copy(target) pattern in every SaveXxx
with FindIndex -> in-place replace (or Add on new). Move the two
hidden defaults out of the deleted Copy bodies: Currency = "CHF"
default now lives in AccountRepository.SaveAccount; BookingDate =
ValueDate default now lives in TransactionRepository.SaveTransaction.

Delete Copy from Account, BookingRule, BookingText, SubClass, and
SplitPredefinition. Keep Transaction.Clone() (renamed from Copy, and
now includes RelatedParty) for the defensive copy in GetTransaction
and ViewService.WithDisplaySign.

Fixes a pre-existing bug where Transaction.Copy silently dropped
RelatedParty on every update save.

42 tests total, all passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 21:57:17 +02:00
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
3a770af016 PR J: log repository save failures via ILogger
BaseRepository takes an ILogger via its constructor and logs the
failing file path and exception in Save's catch block before
rethrowing. Every concrete repository takes ILogger<ThisRepo> and
passes it up so failures are categorised by repository type.

Pulls Microsoft.Extensions.Logging.Abstractions into the Db project.
TransactionRepositoryTestShould and AccountRepositoryTestShould pass
NullLogger<T>.Instance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 21:47:55 +02:00
3677b5cd6e PR I: move service interfaces out of Common
IViewService, IFileService, and IChartService move from
Schaad.Accounting.Common/Interfaces/ to
Schaad.Accounting.Services/Interfaces/. Namespaces are unchanged, so
no consumer needs a using update. Common's vendor <Reference
Include="Schaad.Finance.Api"> can be dropped, matching Common's
documented role as the shared models/DTOs/interfaces layer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 21:44:03 +02:00
7287dbd440 PR H: log bank-statement imports via ILogger<FileService>
FileService.ImportAccountStatementFile now emits Information on the
file being processed and on each account's import count, Warning on
mandator-mismatch skips, and Error on vendor-parser failures. UI-facing
MessageDataset behaviour is unchanged.

Pulls Microsoft.Extensions.Logging.Abstractions into the Services
project; FileServiceTestShould uses NullLogger<FileService>.Instance.

Rest of item 18 (repository logging, MatchOpenBankTransactions
summary) tracked as a follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 21:30:41 +02:00
36 changed files with 277 additions and 185 deletions

View file

@ -103,3 +103,10 @@ 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 class ClassIds
public static class ClassIds
{
public const int Activa = 1;
public const int Passiva = 2;

View file

@ -5,5 +5,10 @@
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,19 +59,5 @@ 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,18 +20,5 @@ 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,14 +10,5 @@ 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,17 +9,5 @@
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,15 +14,5 @@ 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,20 +74,22 @@ namespace Schaad.Accounting.Models
}
/// <summary>
/// Makes a copy
/// Returns a shallow independent copy — used to hand out defensive copies
/// (e.g. from repository reads) without exposing the caller to later mutation.
/// </summary>
public void Copy(Transaction target)
public Transaction Clone() => new()
{
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;
}
Id = Id,
BankTransactionId = BankTransactionId,
BankTransactionText = BankTransactionText,
RelatedParty = RelatedParty,
OriginAccountId = OriginAccountId,
TargetAccountId = TargetAccountId,
Text = Text,
Value = Value,
ValueDate = ValueDate,
BookingDate = BookingDate,
FxRate = FxRate
};
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,4 +7,8 @@
<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,6 +2,7 @@ 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;
@ -17,6 +18,7 @@ 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,
@ -24,7 +26,8 @@ namespace Schaad.Accounting.Services
ITransactionRepository transactionsRepository,
IBankTransactionRepository bankTransactionRepository,
IAccountStatementService accountStatementService,
ICreditCardStatementService creditCardStatementService)
ICreditCardStatementService creditCardStatementService,
ILogger<FileService> logger)
{
this.settingsService = settingsService;
this.accountRepository = accountRepository;
@ -32,6 +35,7 @@ namespace Schaad.Accounting.Services
this.bankTransactionRepository = bankTransactionRepository;
this.accountStatementService = accountStatementService;
this.creditCardStatementService = creditCardStatementService;
this.logger = logger;
}
public string Backup()
@ -46,6 +50,8 @@ 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);
@ -56,6 +62,7 @@ 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));
}
@ -87,6 +94,9 @@ 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);
@ -99,6 +109,8 @@ 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

@ -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);
}
}

View 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);
}
}

View file

@ -6,6 +6,7 @@
<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,12 +10,13 @@ namespace Schaad.Accounting.Services
public class SettingsService : ISettingsService
{
private readonly SettingsDataset settingsDataset;
private string mandator = "Claudio Schaad";
private string mandator;
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 IFxService fxService;
private readonly ISettingsService settingsService;
private readonly IFxConverter fxConverter;
private readonly ILogger<ViewService> logger;
public ViewService(
IAccountRepository accountRepository,
@ -26,16 +26,16 @@ namespace Schaad.Accounting.Services
ITransactionRepository transactionRepository,
ISubclassRepository subclassRepository,
IBookingRuleRepository bookingRuleRepository,
IFxService fxService,
ISettingsService settingsService)
IFxConverter fxConverter,
ILogger<ViewService> logger)
{
this.accountRepository = accountRepository;
this.bankTransactionRepository = bankTransactionRepository;
this.transactionRepository = transactionRepository;
this.subclassRepository = subclassRepository;
this.bookingRuleRepository = bookingRuleRepository;
this.fxService = fxService;
this.settingsService = settingsService;
this.fxConverter = fxConverter;
this.logger = logger;
}
/// <summary>
@ -55,7 +55,6 @@ 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());
@ -67,8 +66,8 @@ namespace Schaad.Accounting.Services
return new AccountDataset(
account: a,
balance: balance,
balanceCHF: fxService.ConvertCurrency(balance, a.Currency, "CHF", settings.FixerIoApiKey),
startBalanceCHF: fxService.ConvertCurrency(a.StartBalance, a.Currency, "CHF", settings.FixerIoApiKey),
balanceCHF: fxConverter.ConvertToChf(balance, a.Currency),
startBalanceCHF: fxConverter.ConvertToChf(a.StartBalance, a.Currency),
className: subclassRepository.GetClass(a.Class),
subClassName: subClassNameByNumber[a.SubClass]);
})
@ -111,9 +110,8 @@ namespace Schaad.Accounting.Services
{
var accountList = GetAccountViewList();
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 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 loss = Math.Abs(accountList.Where(m => m.Class == ClassIds.Expenses).Sum(m => m.BalanceCHF));
var balanceView = new BalanceSheetDataset(
@ -165,8 +163,7 @@ namespace Schaad.Accounting.Services
var originAccount = accountsById[t.OriginAccountId];
if (originAccount.Class == ClassIds.Activa && accountId == t.OriginAccountId)
{
var copy = new Transaction();
t.Copy(copy);
var copy = t.Clone();
copy.Value *= -1;
return copy;
}
@ -211,7 +208,7 @@ namespace Schaad.Accounting.Services
var transactions = transactionRepository.GetTransactionList();
var accounts = accountRepository.GetAccountList();
var bookingRules = bookingRuleRepository.GetBookingRuleList();
var bankTransactions = GetOpenBankTransactionList().OrderBy(t => t.ValueDate);
var bankTransactions = GetOpenBankTransactionList().OrderBy(t => t.ValueDate).ToList();
foreach (var bankTransaction in bankTransactions)
{
var trx = new Transaction(bankTransaction, accounts);
@ -220,6 +217,9 @@ 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,3 +1,4 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -21,7 +22,7 @@ public class AccountRepositoryTestShould : IDisposable
settingsService.GetDbPath().Returns(dbDir);
settingsService.GetLastYearDbPath().Returns(Path.Combine(dbDir, "no-such-dir"));
sut = new AccountRepository(settingsService, new RepositoryCache());
sut = new AccountRepository(settingsService, new RepositoryCache(), NullLogger<AccountRepository>.Instance);
}
public void Dispose()
@ -103,7 +104,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());
var fresh = new AccountRepository(settingsService, new RepositoryCache(), NullLogger<AccountRepository>.Instance);
var accounts = fresh.GetAccountList();
accounts.Count.ShouldBe(1);

View file

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

View 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);
}
}

View file

@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -25,7 +26,7 @@ public class TransactionRepositoryTestShould : IDisposable
accountRepo = Substitute.For<IAccountRepository>();
cache = new RepositoryCache();
sut = new TransactionRepository(settingsService, cache, accountRepo);
sut = new TransactionRepository(settingsService, cache, accountRepo, NullLogger<TransactionRepository>.Instance);
}
public void Dispose()
@ -135,4 +136,43 @@ 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,9 +1,8 @@
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;
@ -15,8 +14,7 @@ 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 IFxService fxService = Substitute.For<IFxService>();
private readonly ISettingsService settingsService = Substitute.For<ISettingsService>();
private readonly IFxConverter fxConverter = Substitute.For<IFxConverter>();
public ViewServiceTestShould()
{
@ -24,15 +22,14 @@ 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.
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));
}
private ViewService BuildService() =>
new(accountRepo, bankTransactionRepo, transactionRepo, subclassRepo, bookingRuleRepo, fxService, settingsService);
new(accountRepo, bankTransactionRepo, transactionRepo, subclassRepo, bookingRuleRepo, fxConverter, NullLogger<ViewService>.Instance);
// --- Balance math ---------------------------------------------------------
@ -82,7 +79,7 @@ public class ViewServiceTestShould
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
// 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);
var account = BuildService().GetAccountViewList().Single();

View file

@ -22,9 +22,6 @@ 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!;
@ -47,17 +44,11 @@ public partial class Home : ComponentBase
bool IsCanceled;
Dictionary<int, string> Files = new();
private bool loaded;
protected override Task OnInitializedAsync()
{
if (loaded == false) {
matchingBankTransactions = viewService.MatchOpenBankTransactions();
accounts = viewService.GetAccountViewList();
bookingTexts = bookingTextRepository.GetBookingTextList().Select(b => b.Text).ToArray();
loaded = true;
}
matchingBankTransactions = viewService.MatchOpenBankTransactions();
accounts = viewService.GetAccountViewList();
bookingTexts = bookingTextRepository.GetBookingTextList().Select(b => b.Text).ToArray();
return base.OnInitializedAsync();
}

View file

@ -31,6 +31,7 @@ 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,6 +7,7 @@
},
"Settings": {
"DataPath": "D:\\Developer\\AccountingData\\",
"FixerIoApiKey": "ee23142f1120bb1223b0e76f98abfa97"
"FixerIoApiKey": "ee23142f1120bb1223b0e76f98abfa97",
"DefaultMandator": "Claudio Schaad"
}
}