Compare commits
7 commits
8926fb1e97
...
786fafefa5
| Author | SHA1 | Date | |
|---|---|---|---|
| 786fafefa5 | |||
| 1f1575b0af | |||
| 3ffe4578c2 | |||
| 857b312f9f | |||
| 3a770af016 | |||
| 3677b5cd6e | |||
| 7287dbd440 |
36 changed files with 277 additions and 185 deletions
|
|
@ -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.
|
- **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.
|
- **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 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`.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
namespace Schaad.Accounting
|
namespace Schaad.Accounting
|
||||||
{
|
{
|
||||||
public class ClassIds
|
public static class ClassIds
|
||||||
{
|
{
|
||||||
public const int Activa = 1;
|
public const int Activa = 1;
|
||||||
public const int Passiva = 2;
|
public const int Passiva = 2;
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,10 @@
|
||||||
public string DataPath { get; set; }
|
public string DataPath { get; set; }
|
||||||
|
|
||||||
public string FixerIoApiKey { 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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -59,19 +59,5 @@ namespace Schaad.Accounting.Models
|
||||||
{
|
{
|
||||||
get { return string.IsNullOrEmpty(Currency) == false && Currency != "CHF"; }
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -20,18 +20,5 @@ namespace Schaad.Accounting.Models
|
||||||
[Display(Name = "Konto")]
|
[Display(Name = "Konto")]
|
||||||
[Required]
|
[Required]
|
||||||
public string AccountId { get; set; }
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -10,14 +10,5 @@ namespace Schaad.Accounting.Models
|
||||||
[Required]
|
[Required]
|
||||||
[MinLength(3)]
|
[MinLength(3)]
|
||||||
public string Text { get; set; }
|
public string Text { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Makes a copy
|
|
||||||
/// </summary>
|
|
||||||
public void Copy(BookingText target)
|
|
||||||
{
|
|
||||||
target.Id = Id;
|
|
||||||
target.Text = Text;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -9,17 +9,5 @@
|
||||||
public decimal BookingValue { get; set; }
|
public decimal BookingValue { get; set; }
|
||||||
|
|
||||||
public string AccountId { 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -14,15 +14,5 @@ namespace Schaad.Accounting.Models
|
||||||
[Required]
|
[Required]
|
||||||
[MinLength(3)]
|
[MinLength(3)]
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Makes a copy
|
|
||||||
/// </summary>
|
|
||||||
public void Copy(SubClass target)
|
|
||||||
{
|
|
||||||
target.Id = Id;
|
|
||||||
target.Name = Name;
|
|
||||||
target.Number = Number;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -74,20 +74,22 @@ namespace Schaad.Accounting.Models
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public void Copy(Transaction target)
|
public Transaction Clone() => new()
|
||||||
{
|
{
|
||||||
target.BankTransactionId = BankTransactionId;
|
Id = Id,
|
||||||
target.BankTransactionText = BankTransactionText;
|
BankTransactionId = BankTransactionId,
|
||||||
target.Id = Id;
|
BankTransactionText = BankTransactionText,
|
||||||
target.OriginAccountId = OriginAccountId;
|
RelatedParty = RelatedParty,
|
||||||
target.TargetAccountId = TargetAccountId;
|
OriginAccountId = OriginAccountId,
|
||||||
target.Text = Text;
|
TargetAccountId = TargetAccountId,
|
||||||
target.Value = Value;
|
Text = Text,
|
||||||
target.ValueDate = ValueDate;
|
Value = Value,
|
||||||
target.BookingDate = BookingDate > DateTime.MinValue ? BookingDate : ValueDate;
|
ValueDate = ValueDate,
|
||||||
target.FxRate = FxRate;
|
BookingDate = BookingDate,
|
||||||
}
|
FxRate = FxRate
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,9 +4,4 @@
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<RootNamespace>Schaad.Accounting</RootNamespace>
|
<RootNamespace>Schaad.Accounting</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
|
||||||
<Reference Include="Schaad.Finance.Api">
|
|
||||||
<HintPath>..\dlls\Schaad.Finance.Api.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
||||||
|
|
@ -11,8 +12,8 @@ namespace Schaad.Accounting.Repositories
|
||||||
{
|
{
|
||||||
private readonly string ACCOUNTS = "Accounts.xml";
|
private readonly string ACCOUNTS = "Accounts.xml";
|
||||||
|
|
||||||
public AccountRepository(ISettingsService settingsService, RepositoryCache cache)
|
public AccountRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<AccountRepository> logger)
|
||||||
: base(settingsService, cache)
|
: base(settingsService, cache, logger)
|
||||||
{
|
{
|
||||||
EnsureAccountsFile();
|
EnsureAccountsFile();
|
||||||
}
|
}
|
||||||
|
|
@ -27,16 +28,22 @@ namespace Schaad.Accounting.Repositories
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void SaveAccount(Account account)
|
public void SaveAccount(Account account)
|
||||||
{
|
{
|
||||||
var accounts = GetAccountList();
|
if (string.IsNullOrEmpty(account.Currency))
|
||||||
var existingAccount = accounts.FirstOrDefault(a => a.Id == account.Id);
|
|
||||||
|
|
||||||
if (existingAccount == null)
|
|
||||||
{
|
{
|
||||||
existingAccount = new Account();
|
account.Currency = "CHF";
|
||||||
accounts.Add(existingAccount);
|
}
|
||||||
account.Id = Guid.NewGuid().ToString();
|
|
||||||
|
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);
|
Save(accounts, ACCOUNTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Schaad.Accounting.Datasets;
|
using Schaad.Accounting.Datasets;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
@ -10,8 +11,8 @@ namespace Schaad.Accounting.Repositories
|
||||||
{
|
{
|
||||||
private readonly string BANK_TRANSACTIONS = "BankTransactions.xml";
|
private readonly string BANK_TRANSACTIONS = "BankTransactions.xml";
|
||||||
|
|
||||||
public BankTransactionRepository(ISettingsService settingsService, RepositoryCache cache)
|
public BankTransactionRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<BankTransactionRepository> logger)
|
||||||
: base(settingsService, cache)
|
: base(settingsService, cache, logger)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Xml;
|
using System.Xml;
|
||||||
using System.Xml.Serialization;
|
using System.Xml.Serialization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
|
|
||||||
namespace Schaad.Accounting.Repositories
|
namespace Schaad.Accounting.Repositories
|
||||||
|
|
@ -18,11 +19,13 @@ namespace Schaad.Accounting.Repositories
|
||||||
|
|
||||||
protected readonly ISettingsService settingsService;
|
protected readonly ISettingsService settingsService;
|
||||||
protected readonly RepositoryCache cache;
|
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.settingsService = settingsService;
|
||||||
this.cache = cache;
|
this.cache = cache;
|
||||||
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void EnsureFileExists(string fileName)
|
protected void EnsureFileExists(string fileName)
|
||||||
|
|
@ -79,8 +82,9 @@ namespace Schaad.Accounting.Repositories
|
||||||
|
|
||||||
File.Move(tmpPath, filePath, overwrite: true);
|
File.Move(tmpPath, filePath, overwrite: true);
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
logger.LogError(ex, "Failed to save {FilePath}", filePath);
|
||||||
if (File.Exists(tmpPath))
|
if (File.Exists(tmpPath))
|
||||||
{
|
{
|
||||||
try { File.Delete(tmpPath); } catch { /* best effort */ }
|
try { File.Delete(tmpPath); } catch { /* best effort */ }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
||||||
|
|
@ -10,8 +11,8 @@ namespace Schaad.Accounting.Repositories
|
||||||
{
|
{
|
||||||
private readonly string BOOKING_RULES = "BookingRules.xml";
|
private readonly string BOOKING_RULES = "BookingRules.xml";
|
||||||
|
|
||||||
public BookingRuleRepository(ISettingsService settingsService, RepositoryCache cache)
|
public BookingRuleRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<BookingRuleRepository> logger)
|
||||||
: base(settingsService, cache)
|
: base(settingsService, cache, logger)
|
||||||
{
|
{
|
||||||
EnsureFileExists(BOOKING_RULES);
|
EnsureFileExists(BOOKING_RULES);
|
||||||
}
|
}
|
||||||
|
|
@ -27,15 +28,16 @@ namespace Schaad.Accounting.Repositories
|
||||||
public void SaveBookingRule(BookingRule bookingRule)
|
public void SaveBookingRule(BookingRule bookingRule)
|
||||||
{
|
{
|
||||||
var bookingRules = GetBookingRuleList();
|
var bookingRules = GetBookingRuleList();
|
||||||
var existingRule = bookingRules.FirstOrDefault(a => a.Id == bookingRule.Id);
|
var idx = bookingRules.FindIndex(r => r.Id == bookingRule.Id);
|
||||||
|
if (idx >= 0)
|
||||||
if (existingRule == null)
|
|
||||||
{
|
{
|
||||||
existingRule = new BookingRule();
|
bookingRules[idx] = bookingRule;
|
||||||
bookingRules.Add(existingRule);
|
}
|
||||||
bookingRule.Id = Guid.NewGuid().ToString();
|
else
|
||||||
|
{
|
||||||
|
bookingRule.Id = Guid.NewGuid().ToString();
|
||||||
|
bookingRules.Add(bookingRule);
|
||||||
}
|
}
|
||||||
bookingRule.Copy(existingRule);
|
|
||||||
Save(bookingRules, BOOKING_RULES);
|
Save(bookingRules, BOOKING_RULES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
||||||
|
|
@ -10,8 +11,8 @@ namespace Schaad.Accounting.Repositories
|
||||||
{
|
{
|
||||||
private readonly string BOOKING_TEXTS = "BookingTexts.xml";
|
private readonly string BOOKING_TEXTS = "BookingTexts.xml";
|
||||||
|
|
||||||
public BookingTextRepository(ISettingsService settingsService, RepositoryCache cache)
|
public BookingTextRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<BookingTextRepository> logger)
|
||||||
: base(settingsService, cache)
|
: base(settingsService, cache, logger)
|
||||||
{
|
{
|
||||||
EnsureFileExists(BOOKING_TEXTS);
|
EnsureFileExists(BOOKING_TEXTS);
|
||||||
}
|
}
|
||||||
|
|
@ -27,15 +28,16 @@ namespace Schaad.Accounting.Repositories
|
||||||
public void SaveBookingText(BookingText bookingText)
|
public void SaveBookingText(BookingText bookingText)
|
||||||
{
|
{
|
||||||
var bookingTexts = GetBookingTextList();
|
var bookingTexts = GetBookingTextList();
|
||||||
var existingText = bookingTexts.FirstOrDefault(a => a.Id == bookingText.Id);
|
var idx = bookingTexts.FindIndex(t => t.Id == bookingText.Id);
|
||||||
|
if (idx >= 0)
|
||||||
if (existingText == null)
|
|
||||||
{
|
{
|
||||||
existingText = new BookingText();
|
bookingTexts[idx] = bookingText;
|
||||||
bookingTexts.Add(existingText);
|
}
|
||||||
bookingText.Id = Guid.NewGuid().ToString();
|
else
|
||||||
|
{
|
||||||
|
bookingText.Id = Guid.NewGuid().ToString();
|
||||||
|
bookingTexts.Add(bookingText);
|
||||||
}
|
}
|
||||||
bookingText.Copy(existingText);
|
|
||||||
Save(bookingTexts, BOOKING_TEXTS);
|
Save(bookingTexts, BOOKING_TEXTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
||||||
|
|
@ -10,8 +11,8 @@ namespace Schaad.Accounting.Repositories
|
||||||
{
|
{
|
||||||
private readonly string SPLIT_PREDEFINITION = "SplitPredefinitions.xml";
|
private readonly string SPLIT_PREDEFINITION = "SplitPredefinitions.xml";
|
||||||
|
|
||||||
public SplitPredefinitionRepository(ISettingsService settingsService, RepositoryCache cache)
|
public SplitPredefinitionRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<SplitPredefinitionRepository> logger)
|
||||||
: base(settingsService, cache)
|
: base(settingsService, cache, logger)
|
||||||
{
|
{
|
||||||
EnsureFileExists(SPLIT_PREDEFINITION);
|
EnsureFileExists(SPLIT_PREDEFINITION);
|
||||||
}
|
}
|
||||||
|
|
@ -27,15 +28,16 @@ namespace Schaad.Accounting.Repositories
|
||||||
public void SaveSplitPredefinition(SplitPredefinition splitPredefinition)
|
public void SaveSplitPredefinition(SplitPredefinition splitPredefinition)
|
||||||
{
|
{
|
||||||
var definitions = GetSplitPredefinitionList();
|
var definitions = GetSplitPredefinitionList();
|
||||||
var existingDefinition = definitions.FirstOrDefault(a => a.Id == splitPredefinition.Id);
|
var idx = definitions.FindIndex(d => d.Id == splitPredefinition.Id);
|
||||||
|
if (idx >= 0)
|
||||||
if (existingDefinition == null)
|
|
||||||
{
|
{
|
||||||
existingDefinition = new SplitPredefinition();
|
definitions[idx] = splitPredefinition;
|
||||||
definitions.Add(existingDefinition);
|
}
|
||||||
splitPredefinition.Id = Guid.NewGuid().ToString();
|
else
|
||||||
|
{
|
||||||
|
splitPredefinition.Id = Guid.NewGuid().ToString();
|
||||||
|
definitions.Add(splitPredefinition);
|
||||||
}
|
}
|
||||||
splitPredefinition.Copy(existingDefinition);
|
|
||||||
Save(definitions, SPLIT_PREDEFINITION);
|
Save(definitions, SPLIT_PREDEFINITION);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
||||||
|
|
@ -11,8 +12,8 @@ namespace Schaad.Accounting.Repositories
|
||||||
private readonly Dictionary<int, string> classes = new Dictionary<int, string>();
|
private readonly Dictionary<int, string> classes = new Dictionary<int, string>();
|
||||||
private readonly string SUBCLASSES = "SubClasses.xml";
|
private readonly string SUBCLASSES = "SubClasses.xml";
|
||||||
|
|
||||||
public SubclassRepository(ISettingsService settingsService, RepositoryCache cache)
|
public SubclassRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<SubclassRepository> logger)
|
||||||
: base(settingsService, cache)
|
: base(settingsService, cache, logger)
|
||||||
{
|
{
|
||||||
EnsureFileExists(SUBCLASSES);
|
EnsureFileExists(SUBCLASSES);
|
||||||
|
|
||||||
|
|
@ -28,20 +29,21 @@ namespace Schaad.Accounting.Repositories
|
||||||
public List<SubClass> GetSubClassList() => LoadList<SubClass>(SUBCLASSES);
|
public List<SubClass> GetSubClassList() => LoadList<SubClass>(SUBCLASSES);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Save a booking text (insert/update)
|
/// Save a subclass (insert/update)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void SaveSubClass(SubClass subClass)
|
public void SaveSubClass(SubClass subClass)
|
||||||
{
|
{
|
||||||
var subclasses = GetSubClassList();
|
var subclasses = GetSubClassList();
|
||||||
var existingSubClass = subclasses.FirstOrDefault(a => a.Id == subClass.Id);
|
var idx = subclasses.FindIndex(s => s.Id == subClass.Id);
|
||||||
|
if (idx >= 0)
|
||||||
if (existingSubClass == null)
|
|
||||||
{
|
{
|
||||||
existingSubClass = new SubClass();
|
subclasses[idx] = subClass;
|
||||||
subclasses.Add(existingSubClass);
|
}
|
||||||
subClass.Id = Guid.NewGuid().ToString();
|
else
|
||||||
|
{
|
||||||
|
subClass.Id = Guid.NewGuid().ToString();
|
||||||
|
subclasses.Add(subClass);
|
||||||
}
|
}
|
||||||
subClass.Copy(existingSubClass);
|
|
||||||
Save(subclasses, SUBCLASSES);
|
Save(subclasses, SUBCLASSES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
||||||
|
|
@ -11,8 +12,8 @@ namespace Schaad.Accounting.Repositories
|
||||||
private readonly IAccountRepository accountRepository;
|
private readonly IAccountRepository accountRepository;
|
||||||
private readonly string TRANSACTIONS = "Transactions.xml";
|
private readonly string TRANSACTIONS = "Transactions.xml";
|
||||||
|
|
||||||
public TransactionRepository(ISettingsService settingsService, RepositoryCache cache, IAccountRepository accountRepository)
|
public TransactionRepository(ISettingsService settingsService, RepositoryCache cache, IAccountRepository accountRepository, ILogger<TransactionRepository> logger)
|
||||||
: base(settingsService, cache)
|
: base(settingsService, cache, logger)
|
||||||
{
|
{
|
||||||
this.accountRepository = accountRepository;
|
this.accountRepository = accountRepository;
|
||||||
}
|
}
|
||||||
|
|
@ -44,17 +45,22 @@ namespace Schaad.Accounting.Repositories
|
||||||
transaction.FxRate = null;
|
transaction.FxRate = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (transaction.BookingDate <= DateTime.MinValue)
|
||||||
|
{
|
||||||
|
transaction.BookingDate = transaction.ValueDate;
|
||||||
|
}
|
||||||
|
|
||||||
var transactionList = GetTransactionList();
|
var transactionList = GetTransactionList();
|
||||||
var existingTransaction = transactionList.FirstOrDefault(a => a.Id == transaction.Id);
|
var idx = transactionList.FindIndex(t => t.Id == transaction.Id);
|
||||||
|
if (idx >= 0)
|
||||||
if (existingTransaction == null)
|
|
||||||
{
|
{
|
||||||
existingTransaction = new Transaction();
|
transactionList[idx] = transaction;
|
||||||
transactionList.Add(existingTransaction);
|
}
|
||||||
transaction.Id = Guid.NewGuid().ToString();
|
else
|
||||||
|
{
|
||||||
|
transaction.Id = Guid.NewGuid().ToString();
|
||||||
|
transactionList.Add(transaction);
|
||||||
}
|
}
|
||||||
transaction.Copy(existingTransaction);
|
|
||||||
Save(transactionList, TRANSACTIONS);
|
Save(transactionList, TRANSACTIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,8 +76,7 @@ namespace Schaad.Accounting.Repositories
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = new Transaction();
|
var result = stored.Clone();
|
||||||
stored.Copy(result);
|
|
||||||
|
|
||||||
// value is stored in CHF -> convert back to foreign currency for display/editing
|
// value is stored in CHF -> convert back to foreign currency for display/editing
|
||||||
var isFxAccount = accountRepository.GetAccount(result.OriginAccountId).IsFxAccount
|
var isFxAccount = accountRepository.GetAccount(result.OriginAccountId).IsFxAccount
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,8 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />
|
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using System.Collections.Generic;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Schaad.Accounting.Datasets;
|
using Schaad.Accounting.Datasets;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Finance.Api;
|
using Schaad.Finance.Api;
|
||||||
|
|
@ -17,6 +18,7 @@ namespace Schaad.Accounting.Services
|
||||||
private readonly ISettingsService settingsService;
|
private readonly ISettingsService settingsService;
|
||||||
private readonly IAccountStatementService accountStatementService;
|
private readonly IAccountStatementService accountStatementService;
|
||||||
private readonly ICreditCardStatementService creditCardStatementService;
|
private readonly ICreditCardStatementService creditCardStatementService;
|
||||||
|
private readonly ILogger<FileService> logger;
|
||||||
|
|
||||||
public FileService(
|
public FileService(
|
||||||
ISettingsService settingsService,
|
ISettingsService settingsService,
|
||||||
|
|
@ -24,7 +26,8 @@ namespace Schaad.Accounting.Services
|
||||||
ITransactionRepository transactionsRepository,
|
ITransactionRepository transactionsRepository,
|
||||||
IBankTransactionRepository bankTransactionRepository,
|
IBankTransactionRepository bankTransactionRepository,
|
||||||
IAccountStatementService accountStatementService,
|
IAccountStatementService accountStatementService,
|
||||||
ICreditCardStatementService creditCardStatementService)
|
ICreditCardStatementService creditCardStatementService,
|
||||||
|
ILogger<FileService> logger)
|
||||||
{
|
{
|
||||||
this.settingsService = settingsService;
|
this.settingsService = settingsService;
|
||||||
this.accountRepository = accountRepository;
|
this.accountRepository = accountRepository;
|
||||||
|
|
@ -32,6 +35,7 @@ namespace Schaad.Accounting.Services
|
||||||
this.bankTransactionRepository = bankTransactionRepository;
|
this.bankTransactionRepository = bankTransactionRepository;
|
||||||
this.accountStatementService = accountStatementService;
|
this.accountStatementService = accountStatementService;
|
||||||
this.creditCardStatementService = creditCardStatementService;
|
this.creditCardStatementService = creditCardStatementService;
|
||||||
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Backup()
|
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
|
// http://www.mikesdotnetting.com/article/288/asp-net-5-uploading-files-with-asp-net-mvc-6
|
||||||
public IReadOnlyList<MessageDataset> ImportAccountStatementFile(string filePath)
|
public IReadOnlyList<MessageDataset> ImportAccountStatementFile(string filePath)
|
||||||
{
|
{
|
||||||
|
logger.LogInformation("Importing account statement file {FilePath}", filePath);
|
||||||
|
|
||||||
var messages = new List<MessageDataset>();
|
var messages = new List<MessageDataset>();
|
||||||
var accountStatementResults = accountStatementService.ReadFile(filePath, Encoding.UTF8);
|
var accountStatementResults = accountStatementService.ReadFile(filePath, Encoding.UTF8);
|
||||||
|
|
||||||
|
|
@ -56,6 +62,7 @@ namespace Schaad.Accounting.Services
|
||||||
|
|
||||||
if (accountStatementResult.IsSuccess == false)
|
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));
|
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);
|
var count = bankTransactionRepository.SaveBankTransactionList(account.AccountNumber, transactionsThisYear);
|
||||||
accountRepository.SaveBankAccountBalance(account.AccountNumber, (decimal)account.EndBalance.Value);
|
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;
|
var status = count == account.Transactions.Count ? MessageStatus.Success : MessageStatus.Info;
|
||||||
message.Add($"{count} von {account.Transactions.Count} Transaktion(en) importiert.", status);
|
message.Add($"{count} von {account.Transactions.Count} Transaktion(en) importiert.", status);
|
||||||
|
|
||||||
|
|
@ -99,6 +109,8 @@ namespace Schaad.Accounting.Services
|
||||||
}
|
}
|
||||||
else
|
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);
|
message.Add($"Falscher Mandant: {account.Transactions.Count} Transaktion(en) nicht importiert.", MessageStatus.Warning);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FreeSpire.PDF" Version="10.2.0" />
|
<PackageReference Include="FreeSpire.PDF" Version="10.2.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />
|
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,13 @@ namespace Schaad.Accounting.Services
|
||||||
public class SettingsService : ISettingsService
|
public class SettingsService : ISettingsService
|
||||||
{
|
{
|
||||||
private readonly SettingsDataset settingsDataset;
|
private readonly SettingsDataset settingsDataset;
|
||||||
private string mandator = "Claudio Schaad";
|
private string mandator;
|
||||||
private int year = DateTime.Now.Year;
|
private int year = DateTime.Now.Year;
|
||||||
|
|
||||||
public SettingsService(SettingsDataset settingsDataset)
|
public SettingsService(SettingsDataset settingsDataset)
|
||||||
{
|
{
|
||||||
this.settingsDataset = settingsDataset;
|
this.settingsDataset = settingsDataset;
|
||||||
|
this.mandator = settingsDataset.DefaultMandator;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int GetYear()
|
public int GetYear()
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Schaad.Accounting.Datasets;
|
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 +17,8 @@ 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;
|
private readonly ILogger<ViewService> logger;
|
||||||
|
|
||||||
public ViewService(
|
public ViewService(
|
||||||
IAccountRepository accountRepository,
|
IAccountRepository accountRepository,
|
||||||
|
|
@ -26,16 +26,16 @@ namespace Schaad.Accounting.Services
|
||||||
ITransactionRepository transactionRepository,
|
ITransactionRepository transactionRepository,
|
||||||
ISubclassRepository subclassRepository,
|
ISubclassRepository subclassRepository,
|
||||||
IBookingRuleRepository bookingRuleRepository,
|
IBookingRuleRepository bookingRuleRepository,
|
||||||
IFxService fxService,
|
IFxConverter fxConverter,
|
||||||
ISettingsService settingsService)
|
ILogger<ViewService> logger)
|
||||||
{
|
{
|
||||||
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;
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -55,7 +55,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 +66,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 +110,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 => fxConverter.ConvertToChf(m.StartBalance, m.Currency)));
|
||||||
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 loss = Math.Abs(accountList.Where(m => m.Class == ClassIds.Expenses).Sum(m => m.BalanceCHF));
|
||||||
|
|
||||||
var balanceView = new BalanceSheetDataset(
|
var balanceView = new BalanceSheetDataset(
|
||||||
|
|
@ -165,8 +163,7 @@ namespace Schaad.Accounting.Services
|
||||||
var originAccount = accountsById[t.OriginAccountId];
|
var originAccount = accountsById[t.OriginAccountId];
|
||||||
if (originAccount.Class == ClassIds.Activa && accountId == t.OriginAccountId)
|
if (originAccount.Class == ClassIds.Activa && accountId == t.OriginAccountId)
|
||||||
{
|
{
|
||||||
var copy = new Transaction();
|
var copy = t.Clone();
|
||||||
t.Copy(copy);
|
|
||||||
copy.Value *= -1;
|
copy.Value *= -1;
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
@ -211,7 +208,7 @@ namespace Schaad.Accounting.Services
|
||||||
var transactions = transactionRepository.GetTransactionList();
|
var transactions = transactionRepository.GetTransactionList();
|
||||||
var accounts = accountRepository.GetAccountList();
|
var accounts = accountRepository.GetAccountList();
|
||||||
var bookingRules = bookingRuleRepository.GetBookingRuleList();
|
var bookingRules = bookingRuleRepository.GetBookingRuleList();
|
||||||
var bankTransactions = GetOpenBankTransactionList().OrderBy(t => t.ValueDate);
|
var bankTransactions = GetOpenBankTransactionList().OrderBy(t => t.ValueDate).ToList();
|
||||||
foreach (var bankTransaction in bankTransactions)
|
foreach (var bankTransaction in bankTransactions)
|
||||||
{
|
{
|
||||||
var trx = new Transaction(bankTransaction, accounts);
|
var trx = new Transaction(bankTransaction, accounts);
|
||||||
|
|
@ -220,6 +217,9 @@ namespace Schaad.Accounting.Services
|
||||||
MatchBankTransactionBySameValueLastMonth(bankTransaction, trx, transactions);
|
MatchBankTransactionBySameValueLastMonth(bankTransaction, trx, transactions);
|
||||||
newTransactionList.Add(trx);
|
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;
|
return newTransactionList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
@ -21,7 +22,7 @@ public class AccountRepositoryTestShould : IDisposable
|
||||||
settingsService.GetDbPath().Returns(dbDir);
|
settingsService.GetDbPath().Returns(dbDir);
|
||||||
settingsService.GetLastYearDbPath().Returns(Path.Combine(dbDir, "no-such-dir"));
|
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()
|
public void Dispose()
|
||||||
|
|
@ -103,7 +104,7 @@ public class AccountRepositoryTestShould : IDisposable
|
||||||
sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
|
sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
|
||||||
|
|
||||||
// Re-construct with the same directory: existing file, no year rollover, no data loss.
|
// 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();
|
var accounts = fresh.GetAccountList();
|
||||||
accounts.Count.ShouldBe(1);
|
accounts.Count.ShouldBe(1);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
@ -26,7 +27,7 @@ public class FileServiceTestShould
|
||||||
|
|
||||||
private FileService BuildService() => new(
|
private FileService BuildService() => new(
|
||||||
settingsService, accountRepo, transactionRepo, bankTransactionRepo,
|
settingsService, accountRepo, transactionRepo, bankTransactionRepo,
|
||||||
accountStatementService, creditCardStatementService);
|
accountStatementService, creditCardStatementService, NullLogger<FileService>.Instance);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EmitHeaderRowAndRunningBalanceWhenExportingTransactionsCsv()
|
public void EmitHeaderRowAndRunningBalanceWhenExportingTransactionsCsv()
|
||||||
|
|
|
||||||
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,3 +1,4 @@
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
@ -25,7 +26,7 @@ public class TransactionRepositoryTestShould : IDisposable
|
||||||
|
|
||||||
accountRepo = Substitute.For<IAccountRepository>();
|
accountRepo = Substitute.For<IAccountRepository>();
|
||||||
cache = new RepositoryCache();
|
cache = new RepositoryCache();
|
||||||
sut = new TransactionRepository(settingsService, cache, accountRepo);
|
sut = new TransactionRepository(settingsService, cache, accountRepo, NullLogger<TransactionRepository>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|
@ -135,4 +136,43 @@ public class TransactionRepositoryTestShould : IDisposable
|
||||||
|
|
||||||
sut.GetTransactionList().ShouldBeEmpty();
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
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 +14,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 +22,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, NullLogger<ViewService>.Instance);
|
||||||
|
|
||||||
// --- Balance math ---------------------------------------------------------
|
// --- Balance math ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -82,7 +79,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();
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,6 @@ public partial class Home : ComponentBase
|
||||||
[Inject]
|
[Inject]
|
||||||
private IDialogService dialogService { get; set; } = null!;
|
private IDialogService dialogService { get; set; } = null!;
|
||||||
|
|
||||||
[Inject]
|
|
||||||
private ISettingsService settingsService { get; set; } = null!;
|
|
||||||
|
|
||||||
[Inject]
|
[Inject]
|
||||||
private IFileService fileService { get; set; } = null!;
|
private IFileService fileService { get; set; } = null!;
|
||||||
|
|
||||||
|
|
@ -47,17 +44,11 @@ public partial class Home : ComponentBase
|
||||||
bool IsCanceled;
|
bool IsCanceled;
|
||||||
Dictionary<int, string> Files = new();
|
Dictionary<int, string> Files = new();
|
||||||
|
|
||||||
private bool loaded;
|
|
||||||
|
|
||||||
protected override Task OnInitializedAsync()
|
protected override Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
if (loaded == false) {
|
matchingBankTransactions = viewService.MatchOpenBankTransactions();
|
||||||
matchingBankTransactions = viewService.MatchOpenBankTransactions();
|
accounts = viewService.GetAccountViewList();
|
||||||
accounts = viewService.GetAccountViewList();
|
bookingTexts = bookingTextRepository.GetBookingTextList().Select(b => b.Text).ToArray();
|
||||||
bookingTexts = bookingTextRepository.GetBookingTextList().Select(b => b.Text).ToArray();
|
|
||||||
loaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return base.OnInitializedAsync();
|
return base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
},
|
},
|
||||||
"Settings": {
|
"Settings": {
|
||||||
"DataPath": "D:\\Developer\\AccountingData\\",
|
"DataPath": "D:\\Developer\\AccountingData\\",
|
||||||
"FixerIoApiKey": "ee23142f1120bb1223b0e76f98abfa97"
|
"FixerIoApiKey": "ee23142f1120bb1223b0e76f98abfa97",
|
||||||
|
"DefaultMandator": "Claudio Schaad"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue