PR P: full async I/O top to bottom (item 19)

Every repository, service, Blazor page/dialog, and test now uses
async/await. Single atomic diff; the codebase does not compile in
intermediate states.

- BaseRepository: LoadListAsync/LoadAsync/SaveAsync return Task<T>;
  per-file locks use SemaphoreSlim so waiters can await; Save
  serialises to a MemoryStream sync (XmlSerializer has no async
  form), then File.WriteAllBytesAsync + sync File.Move.
- RepositoryCache.GetOrLoadAsync takes a Func<Task<List<T>>>.
- All 7 repository interfaces + implementations async.
- All service interfaces + implementations async (except vendor
  IFxService and stateless IFxConverter / SettingsService).
- Every Blazor OnInitializedAsync switches to await base.
- Test suite fully async, 42 tests pass.

AccountRepository.EnsureAccountsFile keeps two .GetAwaiter().GetResult()
bridges because it runs from the constructor.

Null-render guard follow-up (folded in):
Blazor now renders the component once with fields at their initial
values while OnInitializedAsync awaits — so fields declared `= null!`
are actually null on that first render and things like
`accounts.GroupBy(...)` throw ArgumentNullException. Fixed across
Transactions, BalanceReport, BalanceSheetReport, ProfitLossReport,
DetailReport, Assets, Spendings, SpendingsOverTime, TransactionDialog,
TransactionSplitDialog, and BookingRuleDialog:
- Collection fields initialise to [] so first-render loops are empty.
- Single-object data fields become nullable; the razor wraps
  consumption in `@if (field is null) { <p>Lädt…</p> return; }`.
- <PlotlyChart> guarded behind a null check on config/layout/data so
  Plotly.Blazor's @bind doesn't see nulls.
- Header/footer strings initialise to "" instead of null!.

Architectural hygiene on a single-user local Blazor Server app: the
observed win is one File.ReadAllBytesAsync and one
File.WriteAllBytesAsync per Load/Save, and after PR D each file is
loaded at most once per SignalR circuit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claudio Schaad 2026-07-03 18:30:44 +02:00
parent e7ff097397
commit f9084dc074
60 changed files with 699 additions and 695 deletions

View file

@ -101,7 +101,7 @@ Analysis and phased plan produced 2026-07-02. See conversation history for full
- **PR D** — Phase 2 item 9: per-request unit of work / cache. Split off from PR C because it is invasive enough to warrant its own review. - **PR D** — Phase 2 item 9: per-request unit of work / cache. Split off from PR C because it is invasive enough to warrant its own review.
- **PR E** — Phase 3 item 22: ChartService cleanup. Promoted ahead of async because it fixes an active correctness bug — `GetAccountExpensesPerMonth(accountId, year)` was mutating `ISettingsService` on the singleton to hop years, with the year parameter always hardcoded to `DateTime.Now.Year`, so the Spendings-over-time chart discarded the user's header year selection. - **PR E** — Phase 3 item 22: ChartService cleanup. Promoted ahead of async because it fixes an active correctness bug — `GetAccountExpensesPerMonth(accountId, year)` was mutating `ISettingsService` on the singleton to hop years, with the year parameter always hardcoded to `DateTime.Now.Year`, so the Spendings-over-time chart discarded the user's header year selection.
- **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)** — landed as PR P after being explicitly requested. See PR P entry below.
- **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 H** — Phase 2 item 18 (first slice): add `ILogger<FileService>` to `FileService.ImportAccountStatementFile` so bank-statement imports emit `Information` for the file being processed and each account's import count, `Warning` when an account is skipped because it belongs to a different mandator, and `Error` when the vendor parser reports a failure. Rest of the logging (BaseRepository save failures, MatchOpenBankTransactions summary) tracked as a follow-up because it requires threading loggers through all seven repositories.
- **PR I** — Phase 2 item 12: move the service interfaces (`IViewService`, `IFileService`, `IChartService`) from `Schaad.Accounting.Common` into `Schaad.Accounting.Services/Interfaces/`. Namespaces are unchanged (`Schaad.Accounting.Interfaces`), so no consumer needs a `using` update. Drops the `Schaad.Finance.Api` `<Reference>` from `Common.csproj` — Common is now vendor-free and matches its documented role as the "shared models, DTOs, interfaces" layer. - **PR I** — Phase 2 item 12: move the service interfaces (`IViewService`, `IFileService`, `IChartService`) from `Schaad.Accounting.Common` into `Schaad.Accounting.Services/Interfaces/`. Namespaces are unchanged (`Schaad.Accounting.Interfaces`), so no consumer needs a `using` update. Drops the `Schaad.Finance.Api` `<Reference>` from `Common.csproj` — Common is now vendor-free and matches its documented role as the "shared models, DTOs, interfaces" layer.
@ -111,3 +111,4 @@ Analysis and phased plan produced 2026-07-02. See conversation history for full
- **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 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`. - **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`.
- **PR O** — Phase 5 item 26 (partial): convert `BalanceDataset` and `BalanceSheetDataset` to positional records. Both were constructor-initialised value carriers with read-only usage. The rest of item 26 was descoped: the XML-serialised domain models (`Account`, `Transaction`, ...) need mutable public setters for `XmlSerializer`, and value-equality on mutable data is a footgun (hash changes on mutation); source-generated XML serialisers do not exist without switching file formats. `DataSerie` was already a record; `MessageDataset` has a real mutation method (`Add`) and stays a class; `AccountDataset` / `TransactionDataset` / `BookingRuleDataset` inherit from the mutable domain models and can't cleanly become records without a bigger refactor. - **PR O** — Phase 5 item 26 (partial): convert `BalanceDataset` and `BalanceSheetDataset` to positional records. Both were constructor-initialised value carriers with read-only usage. The rest of item 26 was descoped: the XML-serialised domain models (`Account`, `Transaction`, ...) need mutable public setters for `XmlSerializer`, and value-equality on mutable data is a footgun (hash changes on mutation); source-generated XML serialisers do not exist without switching file formats. `DataSerie` was already a record; `MessageDataset` has a real mutation method (`Add`) and stays a class; `AccountDataset` / `TransactionDataset` / `BookingRuleDataset` inherit from the mutable domain models and can't cleanly become records without a bigger refactor.
- **PR P** — Phase 3 item 19: full async I/O top to bottom. `BaseRepository.LoadAsync/SaveAsync/LoadListAsync` return `Task<T>`; the per-file `lock (obj)` becomes `SemaphoreSlim` so it can be `await`-ed. `Save` serialises to a `MemoryStream` synchronously (XmlSerializer has no async form) then writes bytes with `File.WriteAllBytesAsync`; `File.Move` (atomic rename) has no async counterpart in .NET 9 and stays sync. Every repository interface + implementation, every service interface + implementation (except the vendor-owned `IFxService`), every Razor page/dialog `OnInitializedAsync`, and every test-file assertion becomes `async` / `await`. 51 files touched in one atomic diff — the codebase does not compile in intermediate states. `AccountRepository.EnsureAccountsFile` keeps two `.GetAwaiter().GetResult()` bridges because it runs from the constructor (constructors can't be async). The observed async payoff is one `File.ReadAllBytesAsync` and one `File.WriteAllBytesAsync` per Load/Save; after PR D each XML file is deserialised at most once per SignalR circuit, so on this single-user local Blazor Server app this is architectural-hygiene work, not a measurable perf win.

View file

@ -1,20 +1,21 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
namespace Schaad.Accounting.Interfaces namespace Schaad.Accounting.Interfaces
{ {
public interface IAccountRepository public interface IAccountRepository
{ {
List<Account> GetAccountList(); Task<List<Account>> GetAccountListAsync();
void SaveAccount(Account account); Task SaveAccountAsync(Account account);
Account GetAccount(string id); Task<Account> GetAccountAsync(string id);
void DeleteAccount(string id); Task DeleteAccountAsync(string id);
Account GetAccountForBankAccountNumber(string bankAccountNumber); Task<Account> GetAccountForBankAccountNumberAsync(string bankAccountNumber);
void SaveBankAccountBalance(string bankAccountNumber, decimal accountBalance); Task SaveBankAccountBalanceAsync(string bankAccountNumber, decimal accountBalance);
} }
} }

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Datasets; using Schaad.Accounting.Datasets;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -6,10 +7,10 @@ namespace Schaad.Accounting.Interfaces
{ {
public interface IBankTransactionRepository public interface IBankTransactionRepository
{ {
List<BankTransaction> GetBankTransactionList(); Task<List<BankTransaction>> GetBankTransactionListAsync();
int SaveBankTransactionList(string bankAccountNumber, List<BankTransactionDataset> bankTransactions); Task<int> SaveBankTransactionListAsync(string bankAccountNumber, List<BankTransactionDataset> bankTransactions);
BankTransaction GetBankTransaction(string id); Task<BankTransaction> GetBankTransactionAsync(string id);
} }
} }

View file

@ -1,15 +1,17 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
namespace Schaad.Accounting.Interfaces namespace Schaad.Accounting.Interfaces
{ {
public interface IBookingRuleRepository public interface IBookingRuleRepository
{ {
List<BookingRule> GetBookingRuleList(); Task<List<BookingRule>> GetBookingRuleListAsync();
void SaveBookingRule(BookingRule bookingRule); Task SaveBookingRuleAsync(BookingRule bookingRule);
BookingRule GetBookingRule(string id); Task<BookingRule> GetBookingRuleAsync(string id);
void DeleteBookingRule(string id);
Task DeleteBookingRuleAsync(string id);
} }
} }

View file

@ -1,16 +1,17 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
namespace Schaad.Accounting.Interfaces namespace Schaad.Accounting.Interfaces
{ {
public interface IBookingTextRepository public interface IBookingTextRepository
{ {
List<BookingText> GetBookingTextList(); Task<List<BookingText>> GetBookingTextListAsync();
void SaveBookingText(BookingText bookingText); Task SaveBookingTextAsync(BookingText bookingText);
BookingText GetBookingText(string id); Task<BookingText> GetBookingTextAsync(string id);
void DeleteBookingText(string id); Task DeleteBookingTextAsync(string id);
} }
} }

View file

@ -1,12 +1,13 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
namespace Schaad.Accounting.Interfaces namespace Schaad.Accounting.Interfaces
{ {
public interface ISplitPredefinitionRepository public interface ISplitPredefinitionRepository
{ {
List<SplitPredefinition> GetSplitPredefinitionList(); Task<List<SplitPredefinition>> GetSplitPredefinitionListAsync();
void SaveSplitPredefinition(SplitPredefinition splitPredefinition); Task SaveSplitPredefinitionAsync(SplitPredefinition splitPredefinition);
} }
} }

View file

@ -1,17 +1,18 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
namespace Schaad.Accounting.Interfaces namespace Schaad.Accounting.Interfaces
{ {
public interface ISubclassRepository public interface ISubclassRepository
{ {
List<SubClass> GetSubClassList(); Task<List<SubClass>> GetSubClassListAsync();
void SaveSubClass(SubClass subClass); Task SaveSubClassAsync(SubClass subClass);
SubClass GetSubClass(string id); Task<SubClass> GetSubClassAsync(string id);
void DeleteSubClass(string id); Task DeleteSubClassAsync(string id);
Dictionary<int, string> GetClasses(); Dictionary<int, string> GetClasses();

View file

@ -1,15 +1,17 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
namespace Schaad.Accounting.Interfaces namespace Schaad.Accounting.Interfaces
{ {
public interface ITransactionRepository public interface ITransactionRepository
{ {
List<Transaction> GetTransactionList(); Task<List<Transaction>> GetTransactionListAsync();
void SaveTransaction(Transaction transaction); Task SaveTransactionAsync(Transaction transaction);
Transaction GetTransaction(string id); Task<Transaction> GetTransactionAsync(string id);
void DeleteTransaction(string id);
Task DeleteTransactionAsync(string id);
} }
} }

View file

@ -1,7 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -18,22 +19,16 @@ namespace Schaad.Accounting.Repositories
EnsureAccountsFile(); EnsureAccountsFile();
} }
/// <summary> public Task<List<Account>> GetAccountListAsync() => LoadListAsync<Account>(ACCOUNTS);
/// Load accounts
/// </summary>
public List<Account> GetAccountList() => LoadList<Account>(ACCOUNTS);
/// <summary> public async Task SaveAccountAsync(Account account)
/// Save an account (insert/update)
/// </summary>
public void SaveAccount(Account account)
{ {
if (string.IsNullOrEmpty(account.Currency)) if (string.IsNullOrEmpty(account.Currency))
{ {
account.Currency = "CHF"; account.Currency = "CHF";
} }
var accounts = GetAccountList(); var accounts = await GetAccountListAsync();
var idx = accounts.FindIndex(a => a.Id == account.Id); var idx = accounts.FindIndex(a => a.Id == account.Id);
if (idx >= 0) if (idx >= 0)
{ {
@ -44,42 +39,33 @@ namespace Schaad.Accounting.Repositories
account.Id = Guid.NewGuid().ToString(); account.Id = Guid.NewGuid().ToString();
accounts.Add(account); accounts.Add(account);
} }
Save(accounts, ACCOUNTS); await SaveAsync(accounts, ACCOUNTS);
} }
/// <summary> public async Task<Account> GetAccountAsync(string id)
/// Get account
/// </summary>
public Account GetAccount(string id)
{ {
var accounts = GetAccountList(); var accounts = await GetAccountListAsync();
return accounts.FirstOrDefault(t => t.Id == id); return accounts.FirstOrDefault(t => t.Id == id);
} }
/// <summary> public async Task DeleteAccountAsync(string id)
/// Delete account
/// </summary>
public void DeleteAccount(string id)
{ {
var accounts = GetAccountList(); var accounts = await GetAccountListAsync();
accounts.RemoveAll(t => t.Id == id); accounts.RemoveAll(t => t.Id == id);
Save(accounts, ACCOUNTS); await SaveAsync(accounts, ACCOUNTS);
} }
/// <summary> public async Task<Account> GetAccountForBankAccountNumberAsync(string bankAccountNumber)
/// Get account
/// </summary>
public Account GetAccountForBankAccountNumber(string bankAccountNumber)
{ {
var accounts = GetAccountList(); var accounts = await GetAccountListAsync();
return accounts.SingleOrDefault(t => t.BankAccountNumber == bankAccountNumber); return accounts.SingleOrDefault(t => t.BankAccountNumber == bankAccountNumber);
} }
public void SaveBankAccountBalance(string bankAccountNumber, decimal accountBalance) public async Task SaveBankAccountBalanceAsync(string bankAccountNumber, decimal accountBalance)
{ {
var account = GetAccountForBankAccountNumber(bankAccountNumber); var account = await GetAccountForBankAccountNumberAsync(bankAccountNumber);
account.LastBankBalance = accountBalance; account.LastBankBalance = accountBalance;
SaveAccount(account); await SaveAccountAsync(account);
} }
private void EnsureAccountsFile() private void EnsureAccountsFile()
@ -98,7 +84,9 @@ namespace Schaad.Accounting.Repositories
// Year rollover: seed start balances from the previous year's closing values. // Year rollover: seed start balances from the previous year's closing values.
// Bank accounts start at the last known bank balance; cash accounts start at zero. // Bank accounts start at the last known bank balance; cash accounts start at zero.
var accounts = GetAccountList(); // Uses the sync GetAwaiter().GetResult() bridge because this runs from the
// constructor; the file was just copied so the underlying I/O is local.
var accounts = GetAccountListAsync().GetAwaiter().GetResult();
var modified = false; var modified = false;
foreach (var account in accounts.Where(a => a.Class == ClassIds.Activa)) foreach (var account in accounts.Where(a => a.Class == ClassIds.Activa))
{ {
@ -115,7 +103,7 @@ namespace Schaad.Accounting.Repositories
} }
if (modified) if (modified)
{ {
Save(accounts, ACCOUNTS); SaveAsync(accounts, ACCOUNTS).GetAwaiter().GetResult();
} }
} }
} }

View file

@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Schaad.Accounting.Datasets; using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
@ -16,18 +17,15 @@ namespace Schaad.Accounting.Repositories
{ {
} }
/// <summary> public Task<List<BankTransaction>> GetBankTransactionListAsync() => LoadListAsync<BankTransaction>(BANK_TRANSACTIONS);
/// Load bank transactions
/// </summary>
public List<BankTransaction> GetBankTransactionList() => LoadList<BankTransaction>(BANK_TRANSACTIONS);
/// <summary> /// <summary>
/// Save new bank transactions, ignore existing ones (no update) /// Save new bank transactions, ignore existing ones (no update)
/// </summary> /// </summary>
public int SaveBankTransactionList(string bankAccountNumber, List<BankTransactionDataset> bankTransactions) public async Task<int> SaveBankTransactionListAsync(string bankAccountNumber, List<BankTransactionDataset> bankTransactions)
{ {
var count = 0; var count = 0;
var transactions = GetBankTransactionList(); var transactions = await GetBankTransactionListAsync();
foreach (var bankTrx in bankTransactions) foreach (var bankTrx in bankTransactions)
{ {
var transaction = transactions.FirstOrDefault(t => t.Id == bankTrx.Id); var transaction = transactions.FirstOrDefault(t => t.Id == bankTrx.Id);
@ -48,16 +46,13 @@ namespace Schaad.Accounting.Repositories
count++; count++;
} }
} }
Save(transactions, BANK_TRANSACTIONS); await SaveAsync(transactions, BANK_TRANSACTIONS);
return count; return count;
} }
/// <summary> public async Task<BankTransaction> GetBankTransactionAsync(string id)
/// Get bank transaction
/// </summary>
public BankTransaction GetBankTransaction(string id)
{ {
var transactions = GetBankTransactionList(); var transactions = await GetBankTransactionListAsync();
return transactions.FirstOrDefault(t => t.Id == id); return transactions.FirstOrDefault(t => t.Id == id);
} }
} }

View file

@ -1,8 +1,10 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text; using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Xml; using System.Xml;
using System.Xml.Serialization; using System.Xml.Serialization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -12,9 +14,9 @@ namespace Schaad.Accounting.Repositories
{ {
public abstract class BaseRepository public abstract class BaseRepository
{ {
// One lock per absolute file path so concurrent Save+Save and Save+Load are serialized // One semaphore per absolute file path so concurrent SaveAsync+SaveAsync and
// and can't observe a half-written file. // SaveAsync+LoadAsync are serialized and can't observe a half-written file.
private static readonly ConcurrentDictionary<string, object> FileLocks = private static readonly ConcurrentDictionary<string, SemaphoreSlim> FileLocks =
new(StringComparer.OrdinalIgnoreCase); new(StringComparer.OrdinalIgnoreCase);
protected readonly ISettingsService settingsService; protected readonly ISettingsService settingsService;
@ -28,10 +30,17 @@ namespace Schaad.Accounting.Repositories
this.logger = logger; this.logger = logger;
} }
/// <summary>
/// Copies fileName from last year's directory to the current year's directory when the
/// current year doesn't have it yet. Kept synchronous because it runs from repository
/// constructors; File.Copy has no async equivalent in .NET 9 either way.
/// </summary>
protected void EnsureFileExists(string fileName) protected void EnsureFileExists(string fileName)
{ {
string filePath = Path.Combine(settingsService.GetDbPath(), fileName); string filePath = Path.Combine(settingsService.GetDbPath(), fileName);
lock (GetLock(filePath)) var semaphore = GetLock(filePath);
semaphore.Wait();
try
{ {
if (File.Exists(filePath) == false) if (File.Exists(filePath) == false)
{ {
@ -42,16 +51,20 @@ namespace Schaad.Accounting.Repositories
} }
} }
} }
finally
{
semaphore.Release();
}
} }
/// <summary> /// <summary>
/// Load a list, cached per scope. First call reads the XML file; subsequent calls in /// Load a list, cached per scope. First call reads the XML file; subsequent calls in
/// the same scope return the cached list. <see cref="Save"/> invalidates the entry. /// the same scope return the cached list. <see cref="SaveAsync"/> invalidates the entry.
/// </summary> /// </summary>
protected List<T> LoadList<T>(string fileName) protected Task<List<T>> LoadListAsync<T>(string fileName)
{ {
var filePath = Path.Combine(settingsService.GetDbPath(), fileName); var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
return cache.GetOrLoad(filePath, () => Load<List<T>>(fileName) ?? new List<T>()); return cache.GetOrLoadAsync(filePath, async () => await LoadAsync<List<T>>(fileName) ?? new List<T>());
} }
/// <summary> /// <summary>
@ -59,12 +72,14 @@ namespace Schaad.Accounting.Repositories
/// renames it, so a crash mid-write leaves the previous file intact. Invalidates /// renames it, so a crash mid-write leaves the previous file intact. Invalidates
/// the corresponding cache entry. /// the corresponding cache entry.
/// </summary> /// </summary>
protected void Save<T>(T obj, string fileName) protected async Task SaveAsync<T>(T obj, string fileName)
{ {
var filePath = Path.Combine(settingsService.GetDbPath(), fileName); var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
var tmpPath = filePath + ".tmp"; var tmpPath = filePath + ".tmp";
lock (GetLock(filePath)) var semaphore = GetLock(filePath);
await semaphore.WaitAsync();
try
{ {
var settings = new XmlWriterSettings var settings = new XmlWriterSettings
{ {
@ -74,12 +89,16 @@ namespace Schaad.Accounting.Repositories
try try
{ {
using (var writer = XmlWriter.Create(tmpPath, settings)) // XmlSerializer is synchronous — serialize to memory, then write async.
using var memory = new MemoryStream();
using (var writer = XmlWriter.Create(memory, settings))
{ {
var serializer = new XmlSerializer(typeof(T)); var serializer = new XmlSerializer(typeof(T));
serializer.Serialize(writer, obj); serializer.Serialize(writer, obj);
} }
await File.WriteAllBytesAsync(tmpPath, memory.ToArray());
// File.Move has no async form in .NET 9.
File.Move(tmpPath, filePath, overwrite: true); File.Move(tmpPath, filePath, overwrite: true);
} }
catch (Exception ex) catch (Exception ex)
@ -92,34 +111,43 @@ namespace Schaad.Accounting.Repositories
throw; throw;
} }
} }
finally
{
semaphore.Release();
}
cache.Invalidate(filePath); cache.Invalidate(filePath);
} }
/// <summary> /// <summary>
/// Load an object from an xml file (uncached; used for one-off reads such as file /// Load an object from an xml file (uncached; used for one-off reads such as file
/// existence bootstrapping). Prefer <see cref="LoadList{T}"/> for repository reads. /// existence bootstrapping). Prefer <see cref="LoadListAsync{T}"/> for repository reads.
/// </summary> /// </summary>
protected T Load<T>(string fileName) protected async Task<T> LoadAsync<T>(string fileName)
{ {
var filePath = Path.Combine(settingsService.GetDbPath(), fileName); var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
lock (GetLock(filePath)) var semaphore = GetLock(filePath);
await semaphore.WaitAsync();
try
{ {
if (File.Exists(filePath) == false) if (File.Exists(filePath) == false)
{ {
return default(T); return default(T);
} }
using (XmlReader reader = XmlReader.Create(filePath)) var bytes = await File.ReadAllBytesAsync(filePath);
{ using var memory = new MemoryStream(bytes);
var serializer = new XmlSerializer(typeof(T)); var serializer = new XmlSerializer(typeof(T));
return (T)serializer.Deserialize(reader); return (T)serializer.Deserialize(memory);
} }
finally
{
semaphore.Release();
} }
} }
private static object GetLock(string filePath) private static SemaphoreSlim GetLock(string filePath)
=> FileLocks.GetOrAdd(filePath, _ => new object()); => FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1));
} }
} }

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -17,17 +18,11 @@ namespace Schaad.Accounting.Repositories
EnsureFileExists(BOOKING_RULES); EnsureFileExists(BOOKING_RULES);
} }
/// <summary> public Task<List<BookingRule>> GetBookingRuleListAsync() => LoadListAsync<BookingRule>(BOOKING_RULES);
/// Load booking rules
/// </summary>
public List<BookingRule> GetBookingRuleList() => LoadList<BookingRule>(BOOKING_RULES);
/// <summary> public async Task SaveBookingRuleAsync(BookingRule bookingRule)
/// Save a booking rule (insert/update)
/// </summary>
public void SaveBookingRule(BookingRule bookingRule)
{ {
var bookingRules = GetBookingRuleList(); var bookingRules = await GetBookingRuleListAsync();
var idx = bookingRules.FindIndex(r => r.Id == bookingRule.Id); var idx = bookingRules.FindIndex(r => r.Id == bookingRule.Id);
if (idx >= 0) if (idx >= 0)
{ {
@ -38,26 +33,20 @@ namespace Schaad.Accounting.Repositories
bookingRule.Id = Guid.NewGuid().ToString(); bookingRule.Id = Guid.NewGuid().ToString();
bookingRules.Add(bookingRule); bookingRules.Add(bookingRule);
} }
Save(bookingRules, BOOKING_RULES); await SaveAsync(bookingRules, BOOKING_RULES);
} }
/// <summary> public async Task<BookingRule> GetBookingRuleAsync(string id)
/// Get booking rule
/// </summary>
public BookingRule GetBookingRule(string id)
{ {
var bookingRules = GetBookingRuleList(); var bookingRules = await GetBookingRuleListAsync();
return bookingRules.FirstOrDefault(t => t.Id == id); return bookingRules.FirstOrDefault(t => t.Id == id);
} }
/// <summary> public async Task DeleteBookingRuleAsync(string id)
/// Delete booking rule
/// </summary>
public void DeleteBookingRule(string id)
{ {
var bookingRules = GetBookingRuleList(); var bookingRules = await GetBookingRuleListAsync();
bookingRules.RemoveAll(t => t.Id == id); bookingRules.RemoveAll(t => t.Id == id);
Save(bookingRules, BOOKING_RULES); await SaveAsync(bookingRules, BOOKING_RULES);
} }
} }
} }

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -17,17 +18,11 @@ namespace Schaad.Accounting.Repositories
EnsureFileExists(BOOKING_TEXTS); EnsureFileExists(BOOKING_TEXTS);
} }
/// <summary> public Task<List<BookingText>> GetBookingTextListAsync() => LoadListAsync<BookingText>(BOOKING_TEXTS);
/// Load booking texts
/// </summary>
public List<BookingText> GetBookingTextList() => LoadList<BookingText>(BOOKING_TEXTS);
/// <summary> public async Task SaveBookingTextAsync(BookingText bookingText)
/// Save a booking text (insert/update)
/// </summary>
public void SaveBookingText(BookingText bookingText)
{ {
var bookingTexts = GetBookingTextList(); var bookingTexts = await GetBookingTextListAsync();
var idx = bookingTexts.FindIndex(t => t.Id == bookingText.Id); var idx = bookingTexts.FindIndex(t => t.Id == bookingText.Id);
if (idx >= 0) if (idx >= 0)
{ {
@ -38,26 +33,20 @@ namespace Schaad.Accounting.Repositories
bookingText.Id = Guid.NewGuid().ToString(); bookingText.Id = Guid.NewGuid().ToString();
bookingTexts.Add(bookingText); bookingTexts.Add(bookingText);
} }
Save(bookingTexts, BOOKING_TEXTS); await SaveAsync(bookingTexts, BOOKING_TEXTS);
} }
/// <summary> public async Task<BookingText> GetBookingTextAsync(string id)
/// Get booking text
/// </summary>
public BookingText GetBookingText(string id)
{ {
var bookingTexts = GetBookingTextList(); var bookingTexts = await GetBookingTextListAsync();
return bookingTexts.FirstOrDefault(t => t.Id == id); return bookingTexts.FirstOrDefault(t => t.Id == id);
} }
/// <summary> public async Task DeleteBookingTextAsync(string id)
/// Delete booking text
/// </summary>
public void DeleteBookingText(string id)
{ {
var bookingTexts = GetBookingTextList(); var bookingTexts = await GetBookingTextListAsync();
bookingTexts.RemoveAll(t => t.Id == id); bookingTexts.RemoveAll(t => t.Id == id);
Save(bookingTexts, BOOKING_TEXTS); await SaveAsync(bookingTexts, BOOKING_TEXTS);
} }
} }
} }

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -17,17 +18,11 @@ namespace Schaad.Accounting.Repositories
EnsureFileExists(SPLIT_PREDEFINITION); EnsureFileExists(SPLIT_PREDEFINITION);
} }
/// <summary> public Task<List<SplitPredefinition>> GetSplitPredefinitionListAsync() => LoadListAsync<SplitPredefinition>(SPLIT_PREDEFINITION);
/// Load split predefinitions
/// </summary>
public List<SplitPredefinition> GetSplitPredefinitionList() => LoadList<SplitPredefinition>(SPLIT_PREDEFINITION);
/// <summary> public async Task SaveSplitPredefinitionAsync(SplitPredefinition splitPredefinition)
/// Save a split predefinition (insert/update)
/// </summary>
public void SaveSplitPredefinition(SplitPredefinition splitPredefinition)
{ {
var definitions = GetSplitPredefinitionList(); var definitions = await GetSplitPredefinitionListAsync();
var idx = definitions.FindIndex(d => d.Id == splitPredefinition.Id); var idx = definitions.FindIndex(d => d.Id == splitPredefinition.Id);
if (idx >= 0) if (idx >= 0)
{ {
@ -38,7 +33,7 @@ namespace Schaad.Accounting.Repositories
splitPredefinition.Id = Guid.NewGuid().ToString(); splitPredefinition.Id = Guid.NewGuid().ToString();
definitions.Add(splitPredefinition); definitions.Add(splitPredefinition);
} }
Save(definitions, SPLIT_PREDEFINITION); await SaveAsync(definitions, SPLIT_PREDEFINITION);
} }
} }
} }

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -23,17 +24,11 @@ namespace Schaad.Accounting.Repositories
classes.Add(ClassIds.Expenses, "Ausgaben"); classes.Add(ClassIds.Expenses, "Ausgaben");
} }
/// <summary> public Task<List<SubClass>> GetSubClassListAsync() => LoadListAsync<SubClass>(SUBCLASSES);
/// Load subclasses
/// </summary>
public List<SubClass> GetSubClassList() => LoadList<SubClass>(SUBCLASSES);
/// <summary> public async Task SaveSubClassAsync(SubClass subClass)
/// Save a subclass (insert/update)
/// </summary>
public void SaveSubClass(SubClass subClass)
{ {
var subclasses = GetSubClassList(); var subclasses = await GetSubClassListAsync();
var idx = subclasses.FindIndex(s => s.Id == subClass.Id); var idx = subclasses.FindIndex(s => s.Id == subClass.Id);
if (idx >= 0) if (idx >= 0)
{ {
@ -44,23 +39,20 @@ namespace Schaad.Accounting.Repositories
subClass.Id = Guid.NewGuid().ToString(); subClass.Id = Guid.NewGuid().ToString();
subclasses.Add(subClass); subclasses.Add(subClass);
} }
Save(subclasses, SUBCLASSES); await SaveAsync(subclasses, SUBCLASSES);
} }
/// <summary> public async Task<SubClass> GetSubClassAsync(string id)
/// Get subclass
/// </summary>
public SubClass GetSubClass(string id)
{ {
var subclasses = GetSubClassList(); var subclasses = await GetSubClassListAsync();
return subclasses.FirstOrDefault(t => t.Id == id); return subclasses.FirstOrDefault(t => t.Id == id);
} }
public void DeleteSubClass(string id) public async Task DeleteSubClassAsync(string id)
{ {
var subclasses = GetSubClassList(); var subclasses = await GetSubClassListAsync();
subclasses.RemoveAll(t => t.Id == id); subclasses.RemoveAll(t => t.Id == id);
Save(subclasses, SUBCLASSES); await SaveAsync(subclasses, SUBCLASSES);
} }
public Dictionary<int, string> GetClasses() public Dictionary<int, string> GetClasses()

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -18,20 +19,14 @@ namespace Schaad.Accounting.Repositories
this.accountRepository = accountRepository; this.accountRepository = accountRepository;
} }
/// <summary> public Task<List<Transaction>> GetTransactionListAsync() => LoadListAsync<Transaction>(TRANSACTIONS);
/// Get transaction list
/// </summary>
public List<Transaction> GetTransactionList() => LoadList<Transaction>(TRANSACTIONS);
/// <summary> public async Task SaveTransactionAsync(Transaction transaction)
/// Save a transaction (insert/update)
/// </summary>
public void SaveTransaction(Transaction transaction)
{ {
transaction.Value = Math.Abs(transaction.Value); transaction.Value = Math.Abs(transaction.Value);
var originIsFx = accountRepository.GetAccount(transaction.OriginAccountId).IsFxAccount; var originIsFx = (await accountRepository.GetAccountAsync(transaction.OriginAccountId)).IsFxAccount;
var targetIsFx = accountRepository.GetAccount(transaction.TargetAccountId).IsFxAccount; var targetIsFx = (await accountRepository.GetAccountAsync(transaction.TargetAccountId)).IsFxAccount;
var isFxAccount = originIsFx || targetIsFx; var isFxAccount = originIsFx || targetIsFx;
// user enters amount in the foreign account's currency -> convert to CHF for storage // user enters amount in the foreign account's currency -> convert to CHF for storage
@ -50,7 +45,7 @@ namespace Schaad.Accounting.Repositories
transaction.BookingDate = transaction.ValueDate; transaction.BookingDate = transaction.ValueDate;
} }
var transactionList = GetTransactionList(); var transactionList = await GetTransactionListAsync();
var idx = transactionList.FindIndex(t => t.Id == transaction.Id); var idx = transactionList.FindIndex(t => t.Id == transaction.Id);
if (idx >= 0) if (idx >= 0)
{ {
@ -61,15 +56,12 @@ namespace Schaad.Accounting.Repositories
transaction.Id = Guid.NewGuid().ToString(); transaction.Id = Guid.NewGuid().ToString();
transactionList.Add(transaction); transactionList.Add(transaction);
} }
Save(transactionList, TRANSACTIONS); await SaveAsync(transactionList, TRANSACTIONS);
} }
/// <summary> public async Task<Transaction> GetTransactionAsync(string id)
/// Get transaction
/// </summary>
public Transaction GetTransaction(string id)
{ {
var transactions = GetTransactionList(); var transactions = await GetTransactionListAsync();
var stored = transactions.FirstOrDefault(t => t.Id == id); var stored = transactions.FirstOrDefault(t => t.Id == id);
if (stored == null) if (stored == null)
{ {
@ -79,8 +71,8 @@ namespace Schaad.Accounting.Repositories
var result = stored.Clone(); var result = stored.Clone();
// 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 = (await accountRepository.GetAccountAsync(result.OriginAccountId)).IsFxAccount
|| accountRepository.GetAccount(result.TargetAccountId).IsFxAccount; || (await accountRepository.GetAccountAsync(result.TargetAccountId)).IsFxAccount;
if (isFxAccount && result.FxRate != 0) if (isFxAccount && result.FxRate != 0)
{ {
result.Value = result.Value / result.FxRate.Value; result.Value = result.Value / result.FxRate.Value;
@ -89,14 +81,11 @@ namespace Schaad.Accounting.Repositories
return result; return result;
} }
/// <summary> public async Task DeleteTransactionAsync(string id)
/// Delete transaction
/// </summary>
public void DeleteTransaction(string id)
{ {
var transactions = GetTransactionList(); var transactions = await GetTransactionListAsync();
transactions.RemoveAll(t => t.Id == id); transactions.RemoveAll(t => t.Id == id);
Save(transactions, TRANSACTIONS); await SaveAsync(transactions, TRANSACTIONS);
} }
} }
} }

View file

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
namespace Schaad.Accounting.Repositories namespace Schaad.Accounting.Repositories
{ {
@ -7,7 +8,7 @@ namespace Schaad.Accounting.Repositories
/// Per-scope cache of deserialized XML lists, keyed by absolute file path. /// Per-scope cache of deserialized XML lists, keyed by absolute file path.
/// ///
/// Registered as Scoped so it lives for the SignalR circuit; each XML file is /// Registered as Scoped so it lives for the SignalR circuit; each XML file is
/// then loaded at most once per circuit. Writes go through <see cref="BaseRepository.Save"/>, /// then loaded at most once per circuit. Writes go through <see cref="BaseRepository.SaveAsync"/>,
/// which invalidates the corresponding entry so the next read reloads from disk. /// which invalidates the corresponding entry so the next read reloads from disk.
/// ///
/// Not thread-safe — callers within a single Blazor Server circuit run serialized on the /// Not thread-safe — callers within a single Blazor Server circuit run serialized on the
@ -18,14 +19,14 @@ namespace Schaad.Accounting.Repositories
private readonly Dictionary<string, object> entries = private readonly Dictionary<string, object> entries =
new(StringComparer.OrdinalIgnoreCase); new(StringComparer.OrdinalIgnoreCase);
public List<T> GetOrLoad<T>(string cacheKey, Func<List<T>> loader) public async Task<List<T>> GetOrLoadAsync<T>(string cacheKey, Func<Task<List<T>>> loader)
{ {
if (entries.TryGetValue(cacheKey, out var cached)) if (entries.TryGetValue(cacheKey, out var cached))
{ {
return (List<T>)cached; return (List<T>)cached;
} }
var list = loader(); var list = await loader();
entries[cacheKey] = list; entries[cacheKey] = list;
return list; return list;
} }

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Schaad.Accounting.Datasets; using Schaad.Accounting.Datasets;
using Schaad.Accounting.Datasets.Charts; using Schaad.Accounting.Datasets.Charts;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
@ -18,9 +19,9 @@ namespace Schaad.Accounting.Services
this.viewService = viewService; this.viewService = viewService;
} }
public IReadOnlyList<DataSerie> GetExpensesPerMonth() public async Task<IReadOnlyList<DataSerie>> GetExpensesPerMonthAsync()
{ {
var expenseTransactions = viewService.GetTransactionViewList() var expenseTransactions = (await viewService.GetTransactionViewListAsync())
.Where(t => t.TargetAccount.Class == ClassIds.Expenses) .Where(t => t.TargetAccount.Class == ClassIds.Expenses)
.ToList(); .ToList();
@ -31,7 +32,7 @@ namespace Schaad.Accounting.Services
return distinctSubClasses > 1 return distinctSubClasses > 1
? GetSubClassExpensesPerMonth(expenseTransactions) ? GetSubClassExpensesPerMonth(expenseTransactions)
: GetAccountExpensesPerMonth(); : await GetAccountExpensesPerMonthAsync();
} }
private List<DataSerie> GetSubClassExpensesPerMonth(List<TransactionDataset> expenseTransactions) private List<DataSerie> GetSubClassExpensesPerMonth(List<TransactionDataset> expenseTransactions)
@ -59,11 +60,11 @@ namespace Schaad.Accounting.Services
return list; return list;
} }
private List<DataSerie> GetAccountExpensesPerMonth() private async Task<List<DataSerie>> GetAccountExpensesPerMonthAsync()
{ {
var year = settingsService.GetYear(); var year = settingsService.GetYear();
var allTransactions = viewService.GetTransactionViewList(); var allTransactions = await viewService.GetTransactionViewListAsync();
var expenseAccounts = viewService.GetAccountViewList().Where(a => a.Class == ClassIds.Expenses); var expenseAccounts = (await viewService.GetAccountViewListAsync()).Where(a => a.Class == ClassIds.Expenses);
var list = new List<DataSerie>(); var list = new List<DataSerie>();
foreach (var account in expenseAccounts) foreach (var account in expenseAccounts)

View file

@ -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 System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Schaad.Accounting.Datasets; using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
@ -47,15 +48,14 @@ namespace Schaad.Accounting.Services
} }
// Upload account statement file (mt940, camt053) // Upload account statement file (mt940, camt053)
// http://www.mikesdotnetting.com/article/288/asp-net-5-uploading-files-with-asp-net-mvc-6 public async Task<IReadOnlyList<MessageDataset>> ImportAccountStatementFileAsync(string filePath)
public IReadOnlyList<MessageDataset> ImportAccountStatementFile(string filePath)
{ {
logger.LogInformation("Importing account statement file {FilePath}", 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);
var accountList = accountRepository.GetAccountList().Select(a => a.BankAccountNumber).ToList(); var accountList = (await accountRepository.GetAccountListAsync()).Select(a => a.BankAccountNumber).ToList();
foreach (var accountStatementResult in accountStatementResults) foreach (var accountStatementResult in accountStatementResults)
{ {
var account = accountStatementResult.AccountStatement; var account = accountStatementResult.AccountStatement;
@ -91,8 +91,8 @@ namespace Schaad.Accounting.Services
Creditor = t.Creditor Creditor = t.Creditor
}) })
.ToList(); .ToList();
var count = bankTransactionRepository.SaveBankTransactionList(account.AccountNumber, transactionsThisYear); var count = await bankTransactionRepository.SaveBankTransactionListAsync(account.AccountNumber, transactionsThisYear);
accountRepository.SaveBankAccountBalance(account.AccountNumber, (decimal)account.EndBalance.Value); await accountRepository.SaveBankAccountBalanceAsync(account.AccountNumber, (decimal)account.EndBalance.Value);
logger.LogInformation("Imported {Imported} of {Total} transactions for account {AccountNumber}", logger.LogInformation("Imported {Imported} of {Total} transactions for account {AccountNumber}",
count, account.Transactions.Count, account.AccountNumber); count, account.Transactions.Count, account.AccountNumber);
@ -124,11 +124,11 @@ namespace Schaad.Accounting.Services
return creditCardTransactions; return creditCardTransactions;
} }
public byte[] GetTransactionListCsv(string accountId) public async Task<byte[]> GetTransactionListCsvAsync(string accountId)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
var account = accountRepository.GetAccount(accountId); var account = await accountRepository.GetAccountAsync(accountId);
var transactions = transactionsRepository.GetTransactionList() var transactions = (await transactionsRepository.GetTransactionListAsync())
.Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId) .Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId)
.OrderBy(t => t.BookingDate) .OrderBy(t => t.BookingDate)
.ThenBy(t => t.ValueDate) .ThenBy(t => t.ValueDate)

View file

@ -1,10 +1,11 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Datasets.Charts; using Schaad.Accounting.Datasets.Charts;
namespace Schaad.Accounting.Interfaces namespace Schaad.Accounting.Interfaces
{ {
public interface IChartService public interface IChartService
{ {
IReadOnlyList<DataSerie> GetExpensesPerMonth(); Task<IReadOnlyList<DataSerie>> GetExpensesPerMonthAsync();
} }
} }

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Datasets; using Schaad.Accounting.Datasets;
using Schaad.Finance.Api; using Schaad.Finance.Api;
using Schaad.Finance.Api.Datasets; using Schaad.Finance.Api.Datasets;
@ -8,8 +9,8 @@ namespace Schaad.Accounting.Interfaces
public interface IFileService public interface IFileService
{ {
string Backup(); string Backup();
IReadOnlyList<MessageDataset> ImportAccountStatementFile(string filePath); Task<IReadOnlyList<MessageDataset>> ImportAccountStatementFileAsync(string filePath);
IReadOnlyList<CreditCardTransaction> ImportCreditCardStatementFile(CreditCardProvider creditCardProvider, string filePath); IReadOnlyList<CreditCardTransaction> ImportCreditCardStatementFile(CreditCardProvider creditCardProvider, string filePath);
byte[] GetTransactionListCsv(string accountId); Task<byte[]> GetTransactionListCsvAsync(string accountId);
} }
} }

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Datasets; using Schaad.Accounting.Datasets;
using Schaad.Accounting.Datasets.Reports; using Schaad.Accounting.Datasets.Reports;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -8,24 +9,24 @@ namespace Schaad.Accounting.Interfaces
{ {
public interface IViewService public interface IViewService
{ {
AccountDataset GetAccountView(string id); Task<AccountDataset> GetAccountViewAsync(string id);
List<AccountDataset> GetAccountViewList(); Task<List<AccountDataset>> GetAccountViewListAsync();
BalanceDataset GetBalanceView(); Task<BalanceDataset> GetBalanceViewAsync();
BalanceSheetDataset GetBalanceSheetView(int year); Task<BalanceSheetDataset> GetBalanceSheetViewAsync(int year);
List<TransactionDataset> GetTransactionViewList(); Task<List<TransactionDataset>> GetTransactionViewListAsync();
List<TransactionDataset> GetTransactionViewList(string accountId); Task<List<TransactionDataset>> GetTransactionViewListAsync(string accountId);
List<BookingRuleDataset> GetBookingRuleViewList(); Task<List<BookingRuleDataset>> GetBookingRuleViewListAsync();
List<BankTransaction> GetOpenBankTransactionList(); Task<List<BankTransaction>> GetOpenBankTransactionListAsync();
List<Transaction> MatchOpenBankTransactions(); Task<List<Transaction>> MatchOpenBankTransactionsAsync();
List<Transaction> MatchCreditCardTransactions(string bankTransactionId, IReadOnlyList<CreditCardTransaction> creditCardTransactions); Task<List<Transaction>> MatchCreditCardTransactionsAsync(string bankTransactionId, IReadOnlyList<CreditCardTransaction> creditCardTransactions);
} }
} }

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Schaad.Accounting.Datasets; using Schaad.Accounting.Datasets;
using Schaad.Accounting.Datasets.Reports; using Schaad.Accounting.Datasets.Reports;
@ -38,23 +39,17 @@ namespace Schaad.Accounting.Services
this.logger = logger; this.logger = logger;
} }
/// <summary> public async Task<AccountDataset> GetAccountViewAsync(string id)
/// Load accounts
/// </summary>
public AccountDataset GetAccountView(string id)
{ {
var accounts = GetAccountViewList(); var accounts = await GetAccountViewListAsync();
return accounts.Single(t => t.Id == id); return accounts.Single(t => t.Id == id);
} }
/// <summary> public async Task<List<AccountDataset>> GetAccountViewListAsync()
/// Load accounts and calculates their balances
/// </summary>
public List<AccountDataset> GetAccountViewList()
{ {
var accounts = accountRepository.GetAccountList(); var accounts = await accountRepository.GetAccountListAsync();
var transactionList = GetTransactionViewList(); var transactionList = await GetTransactionViewListAsync();
var subClassNameByNumber = subclassRepository.GetSubClassList().ToDictionary(s => s.Number, s => s.Name); var subClassNameByNumber = (await subclassRepository.GetSubClassListAsync()).ToDictionary(s => s.Number, s => s.Name);
// 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());
@ -93,9 +88,9 @@ namespace Schaad.Accounting.Services
return balance; return balance;
} }
public BalanceDataset GetBalanceView() public async Task<BalanceDataset> GetBalanceViewAsync()
{ {
var accountList = GetAccountViewList(); var accountList = await GetAccountViewListAsync();
return new BalanceDataset( return new BalanceDataset(
ActivaAccountList: accountList.Where(m => m.Class == ClassIds.Activa).ToList(), ActivaAccountList: accountList.Where(m => m.Class == ClassIds.Activa).ToList(),
PassivaAccountList: accountList.Where(m => m.Class == ClassIds.Passiva).ToList(), PassivaAccountList: accountList.Where(m => m.Class == ClassIds.Passiva).ToList(),
@ -103,32 +98,30 @@ namespace Schaad.Accounting.Services
TotalPassivaCHF: Math.Abs(accountList.Where(m => m.Class == ClassIds.Passiva).Sum(m => m.BalanceCHF))); TotalPassivaCHF: Math.Abs(accountList.Where(m => m.Class == ClassIds.Passiva).Sum(m => m.BalanceCHF)));
} }
public BalanceSheetDataset GetBalanceSheetView(int year) public async Task<BalanceSheetDataset> GetBalanceSheetViewAsync(int year)
{ {
var accountList = GetAccountViewList(); var accountList = await GetAccountViewListAsync();
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 => fxConverter.ConvertToChf(m.StartBalance, m.Currency)));
var loss = Math.Abs(accountList.Where(m => m.Class == ClassIds.Expenses).Sum(m => m.BalanceCHF)); var loss = Math.Abs(accountList.Where(m => m.Class == ClassIds.Expenses).Sum(m => m.BalanceCHF));
var balanceView = new BalanceSheetDataset( return new BalanceSheetDataset(
ActivaAccountList: accountList.Where(m => m.Class == ClassIds.Activa).ToList(), ActivaAccountList: accountList.Where(m => m.Class == ClassIds.Activa).ToList(),
IncomeAccountList: accountList.Where(m => m.Class == ClassIds.Income).ToList(), IncomeAccountList: accountList.Where(m => m.Class == ClassIds.Income).ToList(),
ExpensesAccountList: accountList.Where(m => m.Class == ClassIds.Expenses).ToList(), ExpensesAccountList: accountList.Where(m => m.Class == ClassIds.Expenses).ToList(),
ProfitCHF: profit, ProfitCHF: profit,
LossCHF: loss, LossCHF: loss,
Year: year); Year: year);
return balanceView;
} }
/// <summary> /// <summary>
/// Get transaction list with the origin and target account for each transaction /// Get transaction list with the origin and target account for each transaction
/// </summary> /// </summary>
public List<TransactionDataset> GetTransactionViewList() public async Task<List<TransactionDataset>> GetTransactionViewListAsync()
{ {
var accountsById = accountRepository.GetAccountList().ToDictionary(a => a.Id); var accountsById = (await accountRepository.GetAccountListAsync()).ToDictionary(a => a.Id);
var transactionList = transactionRepository.GetTransactionList(); var transactionList = await transactionRepository.GetTransactionListAsync();
return transactionList.Select(t => return transactionList.Select(t =>
new TransactionDataset( new TransactionDataset(
@ -139,12 +132,13 @@ namespace Schaad.Accounting.Services
} }
/// <summary> /// <summary>
/// Get transaction list with the origin and target account for each transaction /// Get transaction list filtered to a single account, with values sign-flipped
/// when the requested account is the origin of an activa booking.
/// </summary> /// </summary>
public List<TransactionDataset> GetTransactionViewList(string accountId) public async Task<List<TransactionDataset>> GetTransactionViewListAsync(string accountId)
{ {
var accountsById = accountRepository.GetAccountList().ToDictionary(a => a.Id); var accountsById = (await accountRepository.GetAccountListAsync()).ToDictionary(a => a.Id);
var transactionList = transactionRepository.GetTransactionList() var transactionList = (await transactionRepository.GetTransactionListAsync())
.Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId); .Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId);
return transactionList.Select(t => return transactionList.Select(t =>
@ -170,10 +164,10 @@ namespace Schaad.Accounting.Services
/// <summary> /// <summary>
/// Get booking rules with their account /// Get booking rules with their account
/// </summary> /// </summary>
public List<BookingRuleDataset> GetBookingRuleViewList() public async Task<List<BookingRuleDataset>> GetBookingRuleViewListAsync()
{ {
var accountsById = accountRepository.GetAccountList().ToDictionary(a => a.Id); var accountsById = (await accountRepository.GetAccountListAsync()).ToDictionary(a => a.Id);
var bookingRules = bookingRuleRepository.GetBookingRuleList(); var bookingRules = await bookingRuleRepository.GetBookingRuleListAsync();
return bookingRules.Select(t => return bookingRules.Select(t =>
new BookingRuleDataset(t, accountsById[t.AccountId].Name)) new BookingRuleDataset(t, accountsById[t.AccountId].Name))
@ -183,28 +177,26 @@ namespace Schaad.Accounting.Services
/// <summary> /// <summary>
/// Load open bank transactions /// Load open bank transactions
/// </summary> /// </summary>
public List<BankTransaction> GetOpenBankTransactionList() public async Task<List<BankTransaction>> GetOpenBankTransactionListAsync()
{ {
var transactions = transactionRepository var transactions = (await transactionRepository.GetTransactionListAsync())
.GetTransactionList()
.Where(t => string.IsNullOrEmpty(t.BankTransactionId) == false) .Where(t => string.IsNullOrEmpty(t.BankTransactionId) == false)
.Select(t => t.BankTransactionId); .Select(t => t.BankTransactionId);
var bankTransactions = bankTransactionRepository var bankTransactions = (await bankTransactionRepository.GetBankTransactionListAsync())
.GetBankTransactionList()
.Where(b => b.Ignore == false && transactions.Contains(b.Id) == false) .Where(b => b.Ignore == false && transactions.Contains(b.Id) == false)
.ToList(); .ToList();
return bankTransactions; return bankTransactions;
} }
public List<Transaction> MatchOpenBankTransactions() public async Task<List<Transaction>> MatchOpenBankTransactionsAsync()
{ {
var newTransactionList = new List<Transaction>(); var newTransactionList = new List<Transaction>();
var transactions = transactionRepository.GetTransactionList(); var transactions = await transactionRepository.GetTransactionListAsync();
var accounts = accountRepository.GetAccountList(); var accounts = await accountRepository.GetAccountListAsync();
var bookingRules = bookingRuleRepository.GetBookingRuleList(); var bookingRules = await bookingRuleRepository.GetBookingRuleListAsync();
var bankTransactions = GetOpenBankTransactionList().OrderBy(t => t.ValueDate).ToList(); var bankTransactions = (await GetOpenBankTransactionListAsync()).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);
@ -216,14 +208,15 @@ namespace Schaad.Accounting.Services
var matched = newTransactionList.Count(t => string.IsNullOrEmpty(t.Text) == false); var matched = newTransactionList.Count(t => string.IsNullOrEmpty(t.Text) == false);
logger.LogInformation("Matched {Matched} of {Total} open bank transactions", matched, bankTransactions.Count); logger.LogInformation("Matched {Matched} of {Total} open bank transactions", matched, bankTransactions.Count);
return newTransactionList; return newTransactionList;
} }
public List<Transaction> MatchCreditCardTransactions(string bankTransactionId, IReadOnlyList<CreditCardTransaction> creditCardTransactions) public async Task<List<Transaction>> MatchCreditCardTransactionsAsync(string bankTransactionId, IReadOnlyList<CreditCardTransaction> creditCardTransactions)
{ {
var accounts = accountRepository.GetAccountList(); var accounts = await accountRepository.GetAccountListAsync();
var bankTrx = bankTransactionRepository.GetBankTransaction(bankTransactionId); var bankTrx = await bankTransactionRepository.GetBankTransactionAsync(bankTransactionId);
var bookingRules = bookingRuleRepository.GetBookingRuleList(); var bookingRules = await bookingRuleRepository.GetBookingRuleListAsync();
var trxList = new List<Transaction>(); var trxList = new List<Transaction>();
foreach (var creditCardTransaction in creditCardTransactions) foreach (var creditCardTransaction in creditCardTransactions)

View file

@ -34,79 +34,79 @@ public class AccountRepositoryTestShould : IDisposable
} }
[Fact] [Fact]
public void AssignNewIdWhenSavingAccountWithoutId() public async Task AssignNewIdWhenSavingAccountWithoutId()
{ {
var account = new Account { Number = 1000, Name = "Cash", Currency = "CHF" }; var account = new Account { Number = 1000, Name = "Cash", Currency = "CHF" };
sut.SaveAccount(account); await sut.SaveAccountAsync(account);
var stored = sut.GetAccountList().Single(); var stored = (await sut.GetAccountListAsync()).Single();
stored.Id.ShouldNotBeNullOrEmpty(); stored.Id.ShouldNotBeNullOrEmpty();
stored.Name.ShouldBe("Cash"); stored.Name.ShouldBe("Cash");
} }
[Fact] [Fact]
public void UpdateInPlaceWhenSavingAccountWithExistingId() public async Task UpdateInPlaceWhenSavingAccountWithExistingId()
{ {
sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = "CHF" }); await sut.SaveAccountAsync(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
var id = sut.GetAccountList().Single().Id; var id = (await sut.GetAccountListAsync()).Single().Id;
sut.SaveAccount(new Account { Id = id, Number = 1000, Name = "Cash renamed", Currency = "CHF" }); await sut.SaveAccountAsync(new Account { Id = id, Number = 1000, Name = "Cash renamed", Currency = "CHF" });
var accounts = sut.GetAccountList(); var accounts = await sut.GetAccountListAsync();
accounts.Count.ShouldBe(1); accounts.Count.ShouldBe(1);
accounts[0].Name.ShouldBe("Cash renamed"); accounts[0].Name.ShouldBe("Cash renamed");
} }
[Fact] [Fact]
public void DefaultCurrencyToChfWhenSavingAccountWithoutCurrency() public async Task DefaultCurrencyToChfWhenSavingAccountWithoutCurrency()
{ {
sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = null! }); await sut.SaveAccountAsync(new Account { Number = 1000, Name = "Cash", Currency = null! });
sut.GetAccountList().Single().Currency.ShouldBe("CHF"); (await sut.GetAccountListAsync()).Single().Currency.ShouldBe("CHF");
} }
[Fact] [Fact]
public void RemoveAccountWhenDeletingById() public async Task RemoveAccountWhenDeletingById()
{ {
sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = "CHF" }); await sut.SaveAccountAsync(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
var id = sut.GetAccountList().Single().Id; var id = (await sut.GetAccountListAsync()).Single().Id;
sut.DeleteAccount(id); await sut.DeleteAccountAsync(id);
sut.GetAccountList().ShouldBeEmpty(); (await sut.GetAccountListAsync()).ShouldBeEmpty();
} }
[Fact] [Fact]
public void ReturnAccountWhenLookingUpByBankAccountNumber() public async Task ReturnAccountWhenLookingUpByBankAccountNumber()
{ {
sut.SaveAccount(new Account { Number = 1010, Name = "Checking", Currency = "CHF", BankAccountNumber = "CH01" }); await sut.SaveAccountAsync(new Account { Number = 1010, Name = "Checking", Currency = "CHF", BankAccountNumber = "CH01" });
sut.SaveAccount(new Account { Number = 1020, Name = "Savings", Currency = "CHF", BankAccountNumber = "CH02" }); await sut.SaveAccountAsync(new Account { Number = 1020, Name = "Savings", Currency = "CHF", BankAccountNumber = "CH02" });
sut.GetAccountForBankAccountNumber("CH01").Name.ShouldBe("Checking"); (await sut.GetAccountForBankAccountNumberAsync("CH01")).Name.ShouldBe("Checking");
sut.GetAccountForBankAccountNumber("CH02").Name.ShouldBe("Savings"); (await sut.GetAccountForBankAccountNumberAsync("CH02")).Name.ShouldBe("Savings");
sut.GetAccountForBankAccountNumber("CH99").ShouldBeNull(); (await sut.GetAccountForBankAccountNumberAsync("CH99")).ShouldBeNull();
} }
[Fact] [Fact]
public void UpdateLastBankBalanceWhenSavingBankBalance() public async Task UpdateLastBankBalanceWhenSavingBankBalance()
{ {
sut.SaveAccount(new Account { Number = 1010, Name = "Checking", Currency = "CHF", BankAccountNumber = "CH01" }); await sut.SaveAccountAsync(new Account { Number = 1010, Name = "Checking", Currency = "CHF", BankAccountNumber = "CH01" });
sut.SaveBankAccountBalance("CH01", 1234.56m); await sut.SaveBankAccountBalanceAsync("CH01", 1234.56m);
sut.GetAccountForBankAccountNumber("CH01").LastBankBalance.ShouldBe(1234.56m); (await sut.GetAccountForBankAccountNumberAsync("CH01")).LastBankBalance.ShouldBe(1234.56m);
} }
[Fact] [Fact]
public void SurviveConstructorReRunWhenAccountsFileAlreadyExists() public async Task SurviveConstructorReRunWhenAccountsFileAlreadyExists()
{ {
sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = "CHF" }); await sut.SaveAccountAsync(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(), NullLogger<AccountRepository>.Instance); var fresh = new AccountRepository(settingsService, new RepositoryCache(), NullLogger<AccountRepository>.Instance);
var accounts = fresh.GetAccountList(); var accounts = await fresh.GetAccountListAsync();
accounts.Count.ShouldBe(1); accounts.Count.ShouldBe(1);
accounts[0].Name.ShouldBe("Cash"); accounts[0].Name.ShouldBe("Cash");
} }

View file

@ -20,18 +20,18 @@ public class ChartServiceTestShould
private ChartService BuildService() => new(settingsService, viewService); private ChartService BuildService() => new(settingsService, viewService);
[Fact] [Fact]
public void HonourSelectedYearWhenBuildingMonthlySeries() public async Task HonourSelectedYearWhenBuildingMonthlySeries()
{ {
settingsService.GetYear().Returns(2023); settingsService.GetYear().Returns(2023);
var groceries = MakeAccount("groc", 4200); var groceries = MakeAccount("groc", 4200);
var checking = MakeAccount("chk", 1010); var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewList().Returns(new List<AccountDataset> { AsDataset(groceries), AsDataset(checking) }); viewService.GetAccountViewListAsync().Returns(new List<AccountDataset> { AsDataset(groceries), AsDataset(checking) });
viewService.GetTransactionViewList().Returns(new List<TransactionDataset> viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{ {
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2023, 4, 5)) MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2023, 4, 5))
}); });
var series = BuildService().GetExpensesPerMonth(); var series = await BuildService().GetExpensesPerMonthAsync();
series.Count.ShouldBe(1); series.Count.ShouldBe(1);
series[0].X.ShouldAllBe(x => ((DateOnly)x).Year == 2023); series[0].X.ShouldAllBe(x => ((DateOnly)x).Year == 2023);
@ -40,22 +40,22 @@ public class ChartServiceTestShould
} }
[Fact] [Fact]
public void GroupExpensesByAccountWhenAllExpensesShareOneSubClass() public async Task GroupExpensesByAccountWhenAllExpensesShareOneSubClass()
{ {
var mannenbach = MakeAccount("m1", 4000); // SubClass 40 var mannenbach = MakeAccount("m1", 4000);
var mannenbach2 = MakeAccount("m2", 4001); // SubClass 40 (same) var mannenbach2 = MakeAccount("m2", 4001);
var checking = MakeAccount("chk", 1010); var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewList().Returns(new List<AccountDataset> viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
{ {
AsDataset(mannenbach), AsDataset(mannenbach2), AsDataset(checking) AsDataset(mannenbach), AsDataset(mannenbach2), AsDataset(checking)
}); });
viewService.GetTransactionViewList().Returns(new List<TransactionDataset> viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{ {
MakeTransaction(checking, mannenbach, value: 10m, valueDate: new DateTime(2026, 1, 1)), MakeTransaction(checking, mannenbach, value: 10m, valueDate: new DateTime(2026, 1, 1)),
MakeTransaction(checking, mannenbach2, value: 20m, valueDate: new DateTime(2026, 1, 1)) MakeTransaction(checking, mannenbach2, value: 20m, valueDate: new DateTime(2026, 1, 1))
}); });
var series = BuildService().GetExpensesPerMonth(); var series = await BuildService().GetExpensesPerMonthAsync();
series.Count.ShouldBe(2); series.Count.ShouldBe(2);
series.ShouldContain(s => s.Id == "m1"); series.ShouldContain(s => s.Id == "m1");
@ -63,44 +63,44 @@ public class ChartServiceTestShould
} }
[Fact] [Fact]
public void GroupExpensesBySubClassWhenMultipleSubClassesAreUsed() public async Task GroupExpensesBySubClassWhenMultipleSubClassesAreUsed()
{ {
var groceries = MakeAccount("groc", 4200); // SubClass 42 var groceries = MakeAccount("groc", 4200);
var utilities = MakeAccount("util", 4300); // SubClass 43 var utilities = MakeAccount("util", 4300);
var checking = MakeAccount("chk", 1010); var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewList().Returns(new List<AccountDataset> viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
{ {
AsDataset(groceries), AsDataset(utilities), AsDataset(checking) AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
}); });
viewService.GetTransactionViewList().Returns(new List<TransactionDataset> viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{ {
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 1)), MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 1)),
MakeTransaction(checking, utilities, value: 30m, valueDate: new DateTime(2026, 2, 1)) MakeTransaction(checking, utilities, value: 30m, valueDate: new DateTime(2026, 2, 1))
}); });
var series = BuildService().GetExpensesPerMonth(); var series = await BuildService().GetExpensesPerMonthAsync();
series.Count.ShouldBe(2); series.Count.ShouldBe(2);
series.ShouldContain(s => s.Id == "42"); // sub-class number as id series.ShouldContain(s => s.Id == "42");
series.ShouldContain(s => s.Id == "43"); series.ShouldContain(s => s.Id == "43");
} }
[Fact] [Fact]
public void SkipAccountsWithoutTransactionsWhenBuildingSeries() public async Task SkipAccountsWithoutTransactionsWhenBuildingSeries()
{ {
var groceries = MakeAccount("groc", 4000); // has activity var groceries = MakeAccount("groc", 4000);
var utilities = MakeAccount("util", 4001); // same subclass, no activity var utilities = MakeAccount("util", 4001);
var checking = MakeAccount("chk", 1010); var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewList().Returns(new List<AccountDataset> viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
{ {
AsDataset(groceries), AsDataset(utilities), AsDataset(checking) AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
}); });
viewService.GetTransactionViewList().Returns(new List<TransactionDataset> viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{ {
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 1)) MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 1))
}); });
var series = BuildService().GetExpensesPerMonth(); var series = await BuildService().GetExpensesPerMonthAsync();
series.Count.ShouldBe(1); series.Count.ShouldBe(1);
series[0].Id.ShouldBe("groc"); series[0].Id.ShouldBe("groc");

View file

@ -20,8 +20,6 @@ public class FileServiceTestShould
public FileServiceTestShould() public FileServiceTestShould()
{ {
// Registering the ISO-8859-1 code page provider so the CSV encoding call works
// in the test host (which does not include legacy encodings by default).
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
} }
@ -30,13 +28,11 @@ public class FileServiceTestShould
accountStatementService, creditCardStatementService, NullLogger<FileService>.Instance); accountStatementService, creditCardStatementService, NullLogger<FileService>.Instance);
[Fact] [Fact]
public void EmitHeaderRowAndRunningBalanceWhenExportingTransactionsCsv() public async Task EmitHeaderRowAndRunningBalanceWhenExportingTransactionsCsv()
{ {
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF", StartBalance = 1000m }; var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF", StartBalance = 1000m };
var groceries = new Account { Id = "groc", Number = 4200, Name = "Groceries", Currency = "CHF" }; accountRepo.GetAccountAsync("chk").Returns(checking);
var salary = new Account { Id = "sal", Number = 3000, Name = "Salary", Currency = "CHF" }; transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
accountRepo.GetAccount("chk").Returns(checking);
transactionRepo.GetTransactionList().Returns(new List<Transaction>
{ {
new() new()
{ {
@ -50,26 +46,23 @@ public class FileServiceTestShould
} }
}); });
var bytes = BuildService().GetTransactionListCsv("chk"); var bytes = await BuildService().GetTransactionListCsvAsync("chk");
var csv = Encoding.GetEncoding("ISO-8859-1").GetString(bytes); var csv = Encoding.GetEncoding("ISO-8859-1").GetString(bytes);
var lines = csv.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); var lines = csv.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
lines[0].ShouldBe("Buchungsdatum;Valuta;Buchungstext;Belastung;Gutschrift;Saldo CHF"); lines[0].ShouldBe("Buchungsdatum;Valuta;Buchungstext;Belastung;Gutschrift;Saldo CHF");
lines[1].ShouldBe(";;Startsaldo;;;1000"); lines[1].ShouldBe(";;Startsaldo;;;1000");
// Debit line (outgoing) — value in Belastung column, balance drops
lines[2].ShouldBe("05.01.2026;05.01.2026;Coop;50.00;;950.00"); lines[2].ShouldBe("05.01.2026;05.01.2026;Coop;50.00;;950.00");
// Credit line (incoming) — value in Gutschrift column, balance rises
lines[3].ShouldBe("25.01.2026;25.01.2026;Salary;;5'000.00;5'950.00"); lines[3].ShouldBe("25.01.2026;25.01.2026;Salary;;5'000.00;5'950.00");
} }
[Fact] [Fact]
public void OrderTransactionsByBookingDateThenValueDateThenValueWhenExporting() public async Task OrderTransactionsByBookingDateThenValueDateThenValueWhenExporting()
{ {
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF", StartBalance = 0m }; var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF", StartBalance = 0m };
accountRepo.GetAccount("chk").Returns(checking); accountRepo.GetAccountAsync("chk").Returns(checking);
transactionRepo.GetTransactionList().Returns(new List<Transaction> transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
{ {
// Deliberately unordered
new() { OriginAccountId = "chk", TargetAccountId = "x", Value = 10m, Text = "later", new() { OriginAccountId = "chk", TargetAccountId = "x", Value = 10m, Text = "later",
BookingDate = new DateTime(2026, 2, 1), ValueDate = new DateTime(2026, 2, 1) }, BookingDate = new DateTime(2026, 2, 1), ValueDate = new DateTime(2026, 2, 1) },
new() { OriginAccountId = "chk", TargetAccountId = "x", Value = 5m, Text = "earlier small", new() { OriginAccountId = "chk", TargetAccountId = "x", Value = 5m, Text = "earlier small",
@ -78,30 +71,28 @@ public class FileServiceTestShould
BookingDate = new DateTime(2026, 1, 15), ValueDate = new DateTime(2026, 1, 15) } BookingDate = new DateTime(2026, 1, 15), ValueDate = new DateTime(2026, 1, 15) }
}); });
var csv = Encoding.GetEncoding("ISO-8859-1").GetString(BuildService().GetTransactionListCsv("chk")); var csv = Encoding.GetEncoding("ISO-8859-1").GetString(await BuildService().GetTransactionListCsvAsync("chk"));
var lines = csv.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); var lines = csv.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
// Skip header + start balance rows
lines[2].ShouldContain("earlier small"); lines[2].ShouldContain("earlier small");
lines[3].ShouldContain("earlier large"); lines[3].ShouldContain("earlier large");
lines[4].ShouldContain("later"); lines[4].ShouldContain("later");
} }
[Fact] [Fact]
public void NotMutateTransactionValueWhenExportingCsv() public async Task NotMutateTransactionValueWhenExportingCsv()
{ {
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF" }; var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF" };
accountRepo.GetAccount("chk").Returns(checking); accountRepo.GetAccountAsync("chk").Returns(checking);
var outgoing = new Transaction var outgoing = new Transaction
{ {
OriginAccountId = "chk", TargetAccountId = "x", Value = 42m, Text = "test", OriginAccountId = "chk", TargetAccountId = "x", Value = 42m, Text = "test",
BookingDate = new DateTime(2026, 1, 1), ValueDate = new DateTime(2026, 1, 1) BookingDate = new DateTime(2026, 1, 1), ValueDate = new DateTime(2026, 1, 1)
}; };
transactionRepo.GetTransactionList().Returns(new List<Transaction> { outgoing }); transactionRepo.GetTransactionListAsync().Returns(new List<Transaction> { outgoing });
BuildService().GetTransactionListCsv("chk"); await BuildService().GetTransactionListCsvAsync("chk");
// Regression: earlier code did `trx.Value *= -1` mid-loop.
outgoing.Value.ShouldBe(42m); outgoing.Value.ShouldBe(42m);
} }
} }

View file

@ -6,13 +6,13 @@ namespace Schaad.Accounting.Tests;
public class RepositoryCacheTestShould public class RepositoryCacheTestShould
{ {
[Fact] [Fact]
public void CallLoaderOnceWhenKeyIsRequestedRepeatedly() public async Task CallLoaderOnceWhenKeyIsRequestedRepeatedly()
{ {
var cache = new RepositoryCache(); var cache = new RepositoryCache();
var loaderCalls = 0; var loaderCalls = 0;
var first = cache.GetOrLoad("key", () => { loaderCalls++; return new List<int> { 1, 2 }; }); var first = await cache.GetOrLoadAsync("key", () => { loaderCalls++; return Task.FromResult(new List<int> { 1, 2 }); });
var second = cache.GetOrLoad("key", () => { loaderCalls++; return new List<int> { 99 }; }); var second = await cache.GetOrLoadAsync("key", () => { loaderCalls++; return Task.FromResult(new List<int> { 99 }); });
loaderCalls.ShouldBe(1); loaderCalls.ShouldBe(1);
second.ShouldBeSameAs(first); second.ShouldBeSameAs(first);
@ -20,12 +20,12 @@ public class RepositoryCacheTestShould
} }
[Fact] [Fact]
public void KeepEntriesSeparateWhenKeysDiffer() public async Task KeepEntriesSeparateWhenKeysDiffer()
{ {
var cache = new RepositoryCache(); var cache = new RepositoryCache();
var a = cache.GetOrLoad("a", () => new List<int> { 1 }); var a = await cache.GetOrLoadAsync("a", () => Task.FromResult(new List<int> { 1 }));
var b = cache.GetOrLoad("b", () => new List<int> { 2 }); var b = await cache.GetOrLoadAsync("b", () => Task.FromResult(new List<int> { 2 }));
b.ShouldNotBeSameAs(a); b.ShouldNotBeSameAs(a);
a.ShouldBe(new[] { 1 }); a.ShouldBe(new[] { 1 });
@ -33,25 +33,25 @@ public class RepositoryCacheTestShould
} }
[Fact] [Fact]
public void ReloadFromLoaderWhenKeyIsInvalidated() public async Task ReloadFromLoaderWhenKeyIsInvalidated()
{ {
var cache = new RepositoryCache(); var cache = new RepositoryCache();
var loaderCalls = 0; var loaderCalls = 0;
cache.GetOrLoad("k", () => { loaderCalls++; return new List<int> { 1 }; }); await cache.GetOrLoadAsync("k", () => { loaderCalls++; return Task.FromResult(new List<int> { 1 }); });
cache.Invalidate("k"); cache.Invalidate("k");
cache.GetOrLoad("k", () => { loaderCalls++; return new List<int> { 2 }; }); await cache.GetOrLoadAsync("k", () => { loaderCalls++; return Task.FromResult(new List<int> { 2 }); });
loaderCalls.ShouldBe(2); loaderCalls.ShouldBe(2);
} }
[Fact] [Fact]
public void MatchKeysCaseInsensitivelyWhenLookingUp() public async Task MatchKeysCaseInsensitivelyWhenLookingUp()
{ {
var cache = new RepositoryCache(); var cache = new RepositoryCache();
cache.GetOrLoad("Path/To/File.xml", () => new List<int> { 1 }); await cache.GetOrLoadAsync("Path/To/File.xml", () => Task.FromResult(new List<int> { 1 }));
var upper = cache.GetOrLoad("PATH/TO/FILE.XML", () => new List<int> { 99 }); var upper = await cache.GetOrLoadAsync("PATH/TO/FILE.XML", () => Task.FromResult(new List<int> { 99 }));
upper.ShouldBe(new[] { 1 }); upper.ShouldBe(new[] { 1 });
} }

View file

@ -38,12 +38,12 @@ public class TransactionRepositoryTestShould : IDisposable
} }
[Fact] [Fact]
public void PreserveValueWhenRoundTrippingNonFxTransaction() public async Task PreserveValueWhenRoundTrippingNonFxTransaction()
{ {
accountRepo.GetAccount("chk").Returns(new Account { Id = "chk", Currency = "CHF" }); accountRepo.GetAccountAsync("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
accountRepo.GetAccount("groc").Returns(new Account { Id = "groc", Currency = "CHF" }); accountRepo.GetAccountAsync("groc").Returns(new Account { Id = "groc", Currency = "CHF" });
sut.SaveTransaction(new Transaction await sut.SaveTransactionAsync(new Transaction
{ {
OriginAccountId = "chk", OriginAccountId = "chk",
TargetAccountId = "groc", TargetAccountId = "groc",
@ -53,22 +53,21 @@ public class TransactionRepositoryTestShould : IDisposable
BookingDate = new DateTime(2026, 5, 10) BookingDate = new DateTime(2026, 5, 10)
}); });
var stored = sut.GetTransactionList().Single(); var stored = (await sut.GetTransactionListAsync()).Single();
var loaded = sut.GetTransaction(stored.Id); var loaded = await sut.GetTransactionAsync(stored.Id);
loaded.ShouldNotBeNull(); loaded.ShouldNotBeNull();
loaded.Value.ShouldBe(42m); loaded.Value.ShouldBe(42m);
loaded.FxRate.ShouldBeNull(); // Non-FX transactions have FxRate cleared loaded.FxRate.ShouldBeNull();
} }
[Fact] [Fact]
public void StoreInChfAndReturnInAccountCurrencyWhenTransactionIsFx() public async Task StoreInChfAndReturnInAccountCurrencyWhenTransactionIsFx()
{ {
// User enters 100 USD at rate 0.9 CHF/USD -> stored as 90 CHF; read back as 100 USD. accountRepo.GetAccountAsync("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
accountRepo.GetAccount("chk").Returns(new Account { Id = "chk", Currency = "CHF" }); accountRepo.GetAccountAsync("usd").Returns(new Account { Id = "usd", Currency = "USD" });
accountRepo.GetAccount("usd").Returns(new Account { Id = "usd", Currency = "USD" });
sut.SaveTransaction(new Transaction await sut.SaveTransactionAsync(new Transaction
{ {
OriginAccountId = "chk", OriginAccountId = "chk",
TargetAccountId = "usd", TargetAccountId = "usd",
@ -79,28 +78,28 @@ public class TransactionRepositoryTestShould : IDisposable
BookingDate = new DateTime(2026, 5, 10) BookingDate = new DateTime(2026, 5, 10)
}); });
var stored = sut.GetTransactionList().Single(); var stored = (await sut.GetTransactionListAsync()).Single();
stored.Value.ShouldBe(90m); // stored in CHF stored.Value.ShouldBe(90m);
var loaded = sut.GetTransaction(stored.Id); var loaded = await sut.GetTransactionAsync(stored.Id);
loaded.ShouldNotBeNull(); loaded.ShouldNotBeNull();
loaded.Value.ShouldBe(100m); // display currency loaded.Value.ShouldBe(100m);
loaded.FxRate.ShouldBe(0.9m); loaded.FxRate.ShouldBe(0.9m);
} }
[Fact] [Fact]
public void ReturnNullWhenGettingUnknownTransactionId() public async Task ReturnNullWhenGettingUnknownTransactionId()
{ {
sut.GetTransaction("no-such-id").ShouldBeNull(); (await sut.GetTransactionAsync("no-such-id")).ShouldBeNull();
} }
[Fact] [Fact]
public void NotMutateStoredValueWhenGettingFxTransactionTwice() public async Task NotMutateStoredValueWhenGettingFxTransactionTwice()
{ {
accountRepo.GetAccount("chk").Returns(new Account { Id = "chk", Currency = "CHF" }); accountRepo.GetAccountAsync("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
accountRepo.GetAccount("usd").Returns(new Account { Id = "usd", Currency = "USD" }); accountRepo.GetAccountAsync("usd").Returns(new Account { Id = "usd", Currency = "USD" });
sut.SaveTransaction(new Transaction await sut.SaveTransactionAsync(new Transaction
{ {
OriginAccountId = "chk", OriginAccountId = "chk",
TargetAccountId = "usd", TargetAccountId = "usd",
@ -111,12 +110,11 @@ public class TransactionRepositoryTestShould : IDisposable
BookingDate = new DateTime(2026, 5, 10) BookingDate = new DateTime(2026, 5, 10)
}); });
var stored = sut.GetTransactionList().Single(); var stored = (await sut.GetTransactionListAsync()).Single();
var id = stored.Id; var id = stored.Id;
// Get twice; if Get mutated the stored entity, the second call would divide by FxRate again. var first = await sut.GetTransactionAsync(id);
var first = sut.GetTransaction(id); var second = await sut.GetTransactionAsync(id);
var second = sut.GetTransaction(id);
first.ShouldNotBeNull(); first.ShouldNotBeNull();
first.Value.ShouldBe(100m); first.Value.ShouldBe(100m);
@ -125,24 +123,24 @@ public class TransactionRepositoryTestShould : IDisposable
} }
[Fact] [Fact]
public void RemoveTransactionWhenDeletingById() public async Task RemoveTransactionWhenDeletingById()
{ {
accountRepo.GetAccount(Arg.Any<string>()).Returns(new Account { Currency = "CHF" }); accountRepo.GetAccountAsync(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
sut.SaveTransaction(new Transaction { OriginAccountId = "a", TargetAccountId = "b", Value = 1m, Text = "x", ValueDate = DateTime.Today }); await sut.SaveTransactionAsync(new Transaction { OriginAccountId = "a", TargetAccountId = "b", Value = 1m, Text = "x", ValueDate = DateTime.Today });
var id = sut.GetTransactionList().Single().Id; var id = (await sut.GetTransactionListAsync()).Single().Id;
sut.DeleteTransaction(id); await sut.DeleteTransactionAsync(id);
sut.GetTransactionList().ShouldBeEmpty(); (await sut.GetTransactionListAsync()).ShouldBeEmpty();
} }
[Fact] [Fact]
public void PreserveRelatedPartyWhenRoundTrippingTransaction() public async Task PreserveRelatedPartyWhenRoundTrippingTransaction()
{ {
accountRepo.GetAccount(Arg.Any<string>()).Returns(new Account { Currency = "CHF" }); accountRepo.GetAccountAsync(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
sut.SaveTransaction(new Transaction await sut.SaveTransactionAsync(new Transaction
{ {
OriginAccountId = "a", OriginAccountId = "a",
TargetAccountId = "b", TargetAccountId = "b",
@ -153,26 +151,23 @@ public class TransactionRepositoryTestShould : IDisposable
BookingDate = new DateTime(2026, 4, 1) BookingDate = new DateTime(2026, 4, 1)
}); });
// Regression: earlier Transaction.Copy dropped RelatedParty on every save, (await sut.GetTransactionListAsync()).Single().RelatedParty.ShouldBe("ACME Property AG");
// so the round-trip lost it.
sut.GetTransactionList().Single().RelatedParty.ShouldBe("ACME Property AG");
} }
[Fact] [Fact]
public void DefaultBookingDateToValueDateWhenBookingDateIsUnset() public async Task DefaultBookingDateToValueDateWhenBookingDateIsUnset()
{ {
accountRepo.GetAccount(Arg.Any<string>()).Returns(new Account { Currency = "CHF" }); accountRepo.GetAccountAsync(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
sut.SaveTransaction(new Transaction await sut.SaveTransactionAsync(new Transaction
{ {
OriginAccountId = "a", OriginAccountId = "a",
TargetAccountId = "b", TargetAccountId = "b",
Value = 10m, Value = 10m,
Text = "x", Text = "x",
ValueDate = new DateTime(2026, 4, 15) ValueDate = new DateTime(2026, 4, 15)
// BookingDate left at default (DateTime.MinValue)
}); });
sut.GetTransactionList().Single().BookingDate.ShouldBe(new DateTime(2026, 4, 15)); (await sut.GetTransactionListAsync()).Single().BookingDate.ShouldBe(new DateTime(2026, 4, 15));
} }
} }

View file

@ -18,12 +18,10 @@ public class ViewServiceTestShould
public ViewServiceTestShould() public ViewServiceTestShould()
{ {
// Cover every subclass number a test might reference (Account.SubClass = Number / 100). subclassRepo.GetSubClassListAsync().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("");
// Passthrough FX by default (CHF-only). Individual tests can override.
fxConverter.ConvertToChf(Arg.Any<decimal>(), Arg.Any<string>()) fxConverter.ConvertToChf(Arg.Any<decimal>(), Arg.Any<string>())
.Returns(ci => ci.ArgAt<decimal>(0)); .Returns(ci => ci.ArgAt<decimal>(0));
} }
@ -34,16 +32,16 @@ public class ViewServiceTestShould
// --- Balance math --------------------------------------------------------- // --- Balance math ---------------------------------------------------------
[Fact] [Fact]
public void SumActivaAndPassivaTotalsSeparatelyWhenGettingBalanceView() public async Task SumActivaAndPassivaTotalsSeparatelyWhenGettingBalanceView()
{ {
var cash = Account("cash", 1000, "CHF", startBalance: 100m); var cash = Account("cash", 1000, "CHF", startBalance: 100m);
var receivables = Account("recv", 1100, "CHF", startBalance: 50m); var receivables = Account("recv", 1100, "CHF", startBalance: 50m);
var payables = Account("pay", 2000, "CHF", startBalance: -200m); var payables = Account("pay", 2000, "CHF", startBalance: -200m);
accountRepo.GetAccountList().Returns(new List<Account> { cash, receivables, payables }); accountRepo.GetAccountListAsync().Returns(new List<Account> { cash, receivables, payables });
transactionRepo.GetTransactionList().Returns(new List<Transaction>()); transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>());
var view = BuildService().GetBalanceView(); var view = await BuildService().GetBalanceViewAsync();
view.ActivaAccountList.Count.ShouldBe(2); view.ActivaAccountList.Count.ShouldBe(2);
view.PassivaAccountList.Count.ShouldBe(1); view.PassivaAccountList.Count.ShouldBe(1);
@ -52,109 +50,105 @@ public class ViewServiceTestShould
} }
[Fact] [Fact]
public void ApplyTransactionsToStartBalanceWhenComputingAccountBalance() public async Task ApplyTransactionsToStartBalanceWhenComputingAccountBalance()
{ {
var cash = Account("cash", 1000, "CHF", startBalance: 1000m); var cash = Account("cash", 1000, "CHF", startBalance: 1000m);
var groceries = Account("groc", 4000, "CHF"); var groceries = Account("groc", 4000, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { cash, groceries }); accountRepo.GetAccountListAsync().Returns(new List<Account> { cash, groceries });
// Debit cash 50, credit groceries 50 (spending) transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
transactionRepo.GetTransactionList().Returns(new List<Transaction>
{ {
new() { Id = "t1", OriginAccountId = "cash", TargetAccountId = "groc", Value = 50m, ValueDate = new DateTime(2026, 1, 1) }, new() { Id = "t1", OriginAccountId = "cash", TargetAccountId = "groc", Value = 50m, ValueDate = new DateTime(2026, 1, 1) },
new() { Id = "t2", OriginAccountId = "cash", TargetAccountId = "groc", Value = 30m, ValueDate = new DateTime(2026, 1, 2) }, new() { Id = "t2", OriginAccountId = "cash", TargetAccountId = "groc", Value = 30m, ValueDate = new DateTime(2026, 1, 2) },
}); });
var accounts = BuildService().GetAccountViewList(); var accounts = await BuildService().GetAccountViewListAsync();
accounts.Single(a => a.Id == "cash").Balance.ShouldBe(920m); // 1000 - 80 accounts.Single(a => a.Id == "cash").Balance.ShouldBe(920m);
accounts.Single(a => a.Id == "groc").Balance.ShouldBe(80m); accounts.Single(a => a.Id == "groc").Balance.ShouldBe(80m);
} }
[Fact] [Fact]
public void ConvertBalanceToChfWhenAccountUsesForeignCurrency() public async Task ConvertBalanceToChfWhenAccountUsesForeignCurrency()
{ {
var usd = Account("usd", 1050, "USD", startBalance: 100m); var usd = Account("usd", 1050, "USD", startBalance: 100m);
accountRepo.GetAccountList().Returns(new List<Account> { usd }); accountRepo.GetAccountListAsync().Returns(new List<Account> { usd });
transactionRepo.GetTransactionList().Returns(new List<Transaction>()); transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>());
// 1 USD = 0.90 CHF
fxConverter.ConvertToChf(Arg.Any<decimal>(), "USD") 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 = (await BuildService().GetAccountViewListAsync()).Single();
account.Balance.ShouldBe(100m); // account currency account.Balance.ShouldBe(100m);
account.BalanceCHF.ShouldBe(90m); // FX-converted account.BalanceCHF.ShouldBe(90m);
account.StartBalanceCHF.ShouldBe(90m); account.StartBalanceCHF.ShouldBe(90m);
} }
// --- Bank-transaction auto-matching -------------------------------------- // --- Bank-transaction auto-matching --------------------------------------
[Fact] [Fact]
public void MatchBankTransactionByBookingRuleWhenLookupTextIsContained() public async Task MatchBankTransactionByBookingRuleWhenLookupTextIsContained()
{ {
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01"); var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var utilities = Account("util", 4200, "CHF"); var utilities = Account("util", 4200, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { checking, utilities }); accountRepo.GetAccountListAsync().Returns(new List<Account> { checking, utilities });
bookingRuleRepo.GetBookingRuleList().Returns(new List<BookingRule> bookingRuleRepo.GetBookingRuleListAsync().Returns(new List<BookingRule>
{ {
new() { Id = "r1", LookupText = "electric", AccountId = "util", BookingText = "Strom" } new() { Id = "r1", LookupText = "electric", AccountId = "util", BookingText = "Strom" }
}); });
bankTransactionRepo.GetBankTransactionList().Returns(new List<BankTransaction> bankTransactionRepo.GetBankTransactionListAsync().Returns(new List<BankTransaction>
{ {
new() { Id = "b1", Text = "Monthly Electric Bill", Value = -85m, BankAccountNumber = "CH01", new() { Id = "b1", Text = "Monthly Electric Bill", Value = -85m, BankAccountNumber = "CH01",
ValueDate = new DateTime(2026, 3, 5), BookingDate = new DateTime(2026, 3, 5) } ValueDate = new DateTime(2026, 3, 5), BookingDate = new DateTime(2026, 3, 5) }
}); });
transactionRepo.GetTransactionList().Returns(new List<Transaction>()); transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>());
var matched = BuildService().MatchOpenBankTransactions().Single(); var matched = (await BuildService().MatchOpenBankTransactionsAsync()).Single();
matched.Text.ShouldBe("Strom"); matched.Text.ShouldBe("Strom");
// negative value => bank account is origin; rule fills the target
matched.OriginAccountId.ShouldBe("chk"); matched.OriginAccountId.ShouldBe("chk");
matched.TargetAccountId.ShouldBe("util"); matched.TargetAccountId.ShouldBe("util");
} }
[Fact] [Fact]
public void PreferBookingRuleWithMatchingValueWhenMultipleRulesMatchText() public async Task PreferBookingRuleWithMatchingValueWhenMultipleRulesMatchText()
{ {
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01"); var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var rent = Account("rent", 4100, "CHF"); var rent = Account("rent", 4100, "CHF");
var groceries = Account("groc", 4200, "CHF"); var groceries = Account("groc", 4200, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { checking, rent, groceries }); accountRepo.GetAccountListAsync().Returns(new List<Account> { checking, rent, groceries });
bookingRuleRepo.GetBookingRuleList().Returns(new List<BookingRule> bookingRuleRepo.GetBookingRuleListAsync().Returns(new List<BookingRule>
{ {
new() { Id = "r1", LookupText = "coop", LookupValue = 0m, AccountId = "groc", BookingText = "Groceries" }, new() { Id = "r1", LookupText = "coop", LookupValue = 0m, AccountId = "groc", BookingText = "Groceries" },
new() { Id = "r2", LookupText = "coop", LookupValue = 1200m, AccountId = "rent", BookingText = "Rent via Coop" } new() { Id = "r2", LookupText = "coop", LookupValue = 1200m, AccountId = "rent", BookingText = "Rent via Coop" }
}); });
bankTransactionRepo.GetBankTransactionList().Returns(new List<BankTransaction> bankTransactionRepo.GetBankTransactionListAsync().Returns(new List<BankTransaction>
{ {
new() { Id = "b1", Text = "COOP payment", Value = -1200m, BankAccountNumber = "CH01", new() { Id = "b1", Text = "COOP payment", Value = -1200m, BankAccountNumber = "CH01",
ValueDate = new DateTime(2026, 3, 1), BookingDate = new DateTime(2026, 3, 1) } ValueDate = new DateTime(2026, 3, 1), BookingDate = new DateTime(2026, 3, 1) }
}); });
transactionRepo.GetTransactionList().Returns(new List<Transaction>()); transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>());
var matched = BuildService().MatchOpenBankTransactions().Single(); var matched = (await BuildService().MatchOpenBankTransactionsAsync()).Single();
matched.Text.ShouldBe("Rent via Coop"); matched.Text.ShouldBe("Rent via Coop");
matched.TargetAccountId.ShouldBe("rent"); matched.TargetAccountId.ShouldBe("rent");
} }
[Fact] [Fact]
public void FallBackToSameAccountsLastMonthWhenNoBookingRuleMatches() public async Task FallBackToSameAccountsLastMonthWhenNoBookingRuleMatches()
{ {
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01"); var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var salary = Account("sal", 3000, "CHF"); var salary = Account("sal", 3000, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { checking, salary }); accountRepo.GetAccountListAsync().Returns(new List<Account> { checking, salary });
bookingRuleRepo.GetBookingRuleList().Returns(new List<BookingRule>()); bookingRuleRepo.GetBookingRuleListAsync().Returns(new List<BookingRule>());
bankTransactionRepo.GetBankTransactionList().Returns(new List<BankTransaction> bankTransactionRepo.GetBankTransactionListAsync().Returns(new List<BankTransaction>
{ {
new() { Id = "b2", Text = "Salary Feb", Value = 5000m, BankAccountNumber = "CH01", new() { Id = "b2", Text = "Salary Feb", Value = 5000m, BankAccountNumber = "CH01",
ValueDate = new DateTime(2026, 2, 25), BookingDate = new DateTime(2026, 2, 25) } ValueDate = new DateTime(2026, 2, 25), BookingDate = new DateTime(2026, 2, 25) }
}); });
// A prior-month booking with same accounts + value should be picked up transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
transactionRepo.GetTransactionList().Returns(new List<Transaction>
{ {
new() new()
{ {
@ -168,33 +162,31 @@ public class ViewServiceTestShould
} }
}); });
var matched = BuildService().MatchOpenBankTransactions().Single(); var matched = (await BuildService().MatchOpenBankTransactionsAsync()).Single();
matched.Text.ShouldBe("Monatslohn"); matched.Text.ShouldBe("Monatslohn");
} }
[Fact] [Fact]
public void ExcludeIgnoredAndAlreadyBookedWhenGettingOpenBankTransactions() public async Task ExcludeIgnoredAndAlreadyBookedWhenGettingOpenBankTransactions()
{ {
bankTransactionRepo.GetBankTransactionList().Returns(new List<BankTransaction> bankTransactionRepo.GetBankTransactionListAsync().Returns(new List<BankTransaction>
{ {
new() { Id = "open", Text = "still open", Ignore = false }, new() { Id = "open", Text = "still open", Ignore = false },
new() { Id = "ignored", Text = "ignore me", Ignore = true }, new() { Id = "ignored", Text = "ignore me", Ignore = true },
new() { Id = "booked", Text = "already booked", Ignore = false } new() { Id = "booked", Text = "already booked", Ignore = false }
}); });
transactionRepo.GetTransactionList().Returns(new List<Transaction> transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
{ {
new() { Id = "t1", BankTransactionId = "booked", OriginAccountId = "a", TargetAccountId = "b", Value = 10 } new() { Id = "t1", BankTransactionId = "booked", OriginAccountId = "a", TargetAccountId = "b", Value = 10 }
}); });
var open = BuildService().GetOpenBankTransactionList(); var open = await BuildService().GetOpenBankTransactionListAsync();
open.Count.ShouldBe(1); open.Count.ShouldBe(1);
open[0].Id.ShouldBe("open"); open[0].Id.ShouldBe("open");
} }
// --- Helpers -------------------------------------------------------------
private static Account Account(string id, int number, string currency, decimal startBalance = 0m, string? bankAccountNumber = null) private static Account Account(string id, int number, string currency, decimal startBalance = 0m, string? bankAccountNumber = null)
=> new() => new()
{ {

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -8,7 +8,7 @@ namespace Schaad.Accounting.UI.Components.Pages;
public partial class Accounts : ComponentBase public partial class Accounts : ComponentBase
{ {
[Inject] [Inject]
private IAccountRepository accountRepository { get; set; } = null!; private IAccountRepository accountRepository { get; set; } = null!;
[Inject] [Inject]
@ -16,10 +16,10 @@ public partial class Accounts : ComponentBase
private IQueryable<Account>? accountQueryable; private IQueryable<Account>? accountQueryable;
protected override Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
accountQueryable = accountRepository.GetAccountList().AsQueryable(); accountQueryable = (await accountRepository.GetAccountListAsync()).AsQueryable();
return base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
private async Task AddAsync() private async Task AddAsync()
@ -36,13 +36,13 @@ public partial class Accounts : ComponentBase
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled && result.Data != null) if (!result.Cancelled && result.Data != null)
{ {
accountQueryable = accountRepository.GetAccountList().AsQueryable(); accountQueryable = (await accountRepository.GetAccountListAsync()).AsQueryable();
} }
} }
private async Task EditAsync(string id) private async Task EditAsync(string id)
{ {
var data = accountRepository.GetAccount(id); var data = await accountRepository.GetAccountAsync(id);
var dialog = await dialogService.ShowDialogAsync<AccountDialog>(data, new DialogParameters() var dialog = await dialogService.ShowDialogAsync<AccountDialog>(data, new DialogParameters()
{ {
Height = "500px", Height = "500px",
@ -54,19 +54,19 @@ public partial class Accounts : ComponentBase
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled && result.Data != null) if (!result.Cancelled && result.Data != null)
{ {
accountQueryable = accountRepository.GetAccountList().AsQueryable(); accountQueryable = (await accountRepository.GetAccountListAsync()).AsQueryable();
} }
} }
private async Task DeleteAsync(string id) private async Task DeleteAsync(string id)
{ {
var account = accountRepository.GetAccount(id); var account = await accountRepository.GetAccountAsync(id);
var dialog = await dialogService.ShowConfirmationAsync($"Konto '{account.Name}' wirklich löschen?", "Ja", "Nein", "Konto löschen"); var dialog = await dialogService.ShowConfirmationAsync($"Konto '{account.Name}' wirklich löschen?", "Ja", "Nein", "Konto löschen");
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled) if (!result.Cancelled)
{ {
accountRepository.DeleteAccount(id); await accountRepository.DeleteAccountAsync(id);
accountQueryable = accountRepository.GetAccountList().AsQueryable(); accountQueryable = (await accountRepository.GetAccountListAsync()).AsQueryable();
} }
} }
} }

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -10,9 +10,9 @@ public partial class BankTransactions : ComponentBase
private IBankTransactionRepository bankTransactionRepository { get; set; } = null!; private IBankTransactionRepository bankTransactionRepository { get; set; } = null!;
private IQueryable<BankTransaction>? TransactionList; private IQueryable<BankTransaction>? TransactionList;
protected override Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
TransactionList = bankTransactionRepository.GetBankTransactionList().AsQueryable(); TransactionList = (await bankTransactionRepository.GetBankTransactionListAsync()).AsQueryable();
return base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
} }

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Datasets; using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
@ -20,10 +20,10 @@ public partial class BookingRules : ComponentBase
private IQueryable<BookingRuleDataset>? bookingRulesQueryable; private IQueryable<BookingRuleDataset>? bookingRulesQueryable;
protected override Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable(); bookingRulesQueryable = (await viewService.GetBookingRuleViewListAsync()).AsQueryable();
return base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
private async Task AddAsync() private async Task AddAsync()
@ -40,13 +40,13 @@ public partial class BookingRules : ComponentBase
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled && result.Data != null) if (!result.Cancelled && result.Data != null)
{ {
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable(); bookingRulesQueryable = (await viewService.GetBookingRuleViewListAsync()).AsQueryable();
} }
} }
private async Task EditAsync(string id) private async Task EditAsync(string id)
{ {
var data = bookingRuleRepository.GetBookingRule(id); var data = await bookingRuleRepository.GetBookingRuleAsync(id);
var dialog = await dialogService.ShowDialogAsync<BookingRuleDialog>(data, new DialogParameters() var dialog = await dialogService.ShowDialogAsync<BookingRuleDialog>(data, new DialogParameters()
{ {
Height = "500px", Height = "500px",
@ -58,19 +58,19 @@ public partial class BookingRules : ComponentBase
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled && result.Data != null) if (!result.Cancelled && result.Data != null)
{ {
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable(); bookingRulesQueryable = (await viewService.GetBookingRuleViewListAsync()).AsQueryable();
} }
} }
private async Task DeleteAsync(string id) private async Task DeleteAsync(string id)
{ {
var bookingRule = bookingRuleRepository.GetBookingRule(id); var bookingRule = await bookingRuleRepository.GetBookingRuleAsync(id);
var dialog = await dialogService.ShowConfirmationAsync($"Regel '{bookingRule.LookupText}' wirklich löschen?", "Ja", "Nein", "Buchungsregel löschen"); var dialog = await dialogService.ShowConfirmationAsync($"Regel '{bookingRule.LookupText}' wirklich löschen?", "Ja", "Nein", "Buchungsregel löschen");
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled) if (!result.Cancelled)
{ {
bookingRuleRepository.DeleteBookingRule(id); await bookingRuleRepository.DeleteBookingRuleAsync(id);
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable(); bookingRulesQueryable = (await viewService.GetBookingRuleViewListAsync()).AsQueryable();
} }
} }
} }

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -16,10 +16,10 @@ public partial class BookingTexts : ComponentBase
private IQueryable<BookingText>? bookingTextQueryable; private IQueryable<BookingText>? bookingTextQueryable;
protected override Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable(); bookingTextQueryable = (await bookingTextRepository.GetBookingTextListAsync()).AsQueryable();
return base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
private async Task AddAsync() private async Task AddAsync()
@ -36,13 +36,13 @@ public partial class BookingTexts : ComponentBase
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled && result.Data != null) if (!result.Cancelled && result.Data != null)
{ {
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable(); bookingTextQueryable = (await bookingTextRepository.GetBookingTextListAsync()).AsQueryable();
} }
} }
private async Task EditAsync(string id) private async Task EditAsync(string id)
{ {
var data = bookingTextRepository.GetBookingText(id); var data = await bookingTextRepository.GetBookingTextAsync(id);
var dialog = await dialogService.ShowDialogAsync<BookingTextDialog>(data, new DialogParameters() var dialog = await dialogService.ShowDialogAsync<BookingTextDialog>(data, new DialogParameters()
{ {
Height = "500px", Height = "500px",
@ -54,19 +54,19 @@ public partial class BookingTexts : ComponentBase
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled && result.Data != null) if (!result.Cancelled && result.Data != null)
{ {
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable(); bookingTextQueryable = (await bookingTextRepository.GetBookingTextListAsync()).AsQueryable();
} }
} }
private async Task DeleteAsync(string id) private async Task DeleteAsync(string id)
{ {
var bookingText = bookingTextRepository.GetBookingText(id); var bookingText = await bookingTextRepository.GetBookingTextAsync(id);
var dialog = await dialogService.ShowConfirmationAsync($"Buchungstext '{bookingText.Text}' wirklich löschen?", "Ja", "Nein", "Buchungstext löschen"); var dialog = await dialogService.ShowConfirmationAsync($"Buchungstext '{bookingText.Text}' wirklich löschen?", "Ja", "Nein", "Buchungstext löschen");
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled) if (!result.Cancelled)
{ {
bookingTextRepository.DeleteBookingText(id); await bookingTextRepository.DeleteBookingTextAsync(id);
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable(); bookingTextQueryable = (await bookingTextRepository.GetBookingTextListAsync()).AsQueryable();
} }
} }
} }

View file

@ -3,4 +3,7 @@
<PageTitle>Ausgaben</PageTitle> <PageTitle>Ausgaben</PageTitle>
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" /> @if (config is not null && layout is not null && data is not null)
{
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
}

View file

@ -8,14 +8,14 @@ namespace Schaad.Accounting.UI.Components.Pages.Charts;
public partial class Assets : ComponentBase public partial class Assets : ComponentBase
{ {
private PlotlyChart chart = null!; private PlotlyChart chart = null!;
private Config config = null!; private Config? config;
private Plotly.Blazor.Layout layout = null!; private Plotly.Blazor.Layout? layout;
private IList<ITrace> data = null!; private IList<ITrace>? data;
[Inject] [Inject]
private IViewService viewService { get; set; } = null!; private IViewService viewService { get; set; } = null!;
protected override Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
config = new Config config = new Config
{ {
@ -26,7 +26,7 @@ public partial class Assets : ComponentBase
{ {
}; };
var accounts = viewService.GetAccountViewList().Where(a => a.Class == ClassIds.Activa); var accounts = (await viewService.GetAccountViewListAsync()).Where(a => a.Class == ClassIds.Activa);
var values = new List<object>(); var values = new List<object>();
var labels = new List<object>(); var labels = new List<object>();
var ids = new List<object>(); var ids = new List<object>();
@ -50,6 +50,6 @@ public partial class Assets : ComponentBase
} }
}; };
return base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
} }

View file

@ -3,4 +3,7 @@
<PageTitle>Ausgaben</PageTitle> <PageTitle>Ausgaben</PageTitle>
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" /> @if (config is not null && layout is not null && data is not null)
{
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
}

View file

@ -8,14 +8,14 @@ namespace Schaad.Accounting.UI.Components.Pages.Charts;
public partial class Spendings : ComponentBase public partial class Spendings : ComponentBase
{ {
private PlotlyChart chart = null!; private PlotlyChart chart = null!;
private Config config = null!; private Config? config;
private Plotly.Blazor.Layout layout = null!; private Plotly.Blazor.Layout? layout;
private IList<ITrace> data = null!; private IList<ITrace>? data;
[Inject] [Inject]
private IViewService viewService { get; set; } = null!; private IViewService viewService { get; set; } = null!;
protected override Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
config = new Config config = new Config
{ {
@ -26,7 +26,7 @@ public partial class Spendings : ComponentBase
{ {
}; };
var accounts = viewService.GetAccountViewList().Where(a => a.Class == ClassIds.Expenses); var accounts = (await viewService.GetAccountViewListAsync()).Where(a => a.Class == ClassIds.Expenses);
var values = new List<object>(); var values = new List<object>();
var labels = new List<object>(); var labels = new List<object>();
foreach (var grp in accounts.GroupBy(a => a.SubClass).Select(a => new {Key = a.Key, List = a.ToList()})) foreach (var grp in accounts.GroupBy(a => a.SubClass).Select(a => new {Key = a.Key, List = a.ToList()}))
@ -47,6 +47,6 @@ public partial class Spendings : ComponentBase
} }
}; };
return base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
} }

View file

@ -3,4 +3,7 @@
<PageTitle>Ausgaben im Verlauf</PageTitle> <PageTitle>Ausgaben im Verlauf</PageTitle>
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" /> @if (config is not null && layout is not null)
{
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
}

View file

@ -9,14 +9,14 @@ namespace Schaad.Accounting.UI.Components.Pages.Charts;
public partial class SpendingsOverTime : ComponentBase public partial class SpendingsOverTime : ComponentBase
{ {
private PlotlyChart chart = null!; private PlotlyChart chart = null!;
private Config config = null!; private Config? config;
private Plotly.Blazor.Layout layout = null!; private Plotly.Blazor.Layout? layout;
private IList<ITrace> data = new List<ITrace>(); private IList<ITrace> data = new List<ITrace>();
[Inject] [Inject]
private IChartService chartService { get; set; } = null!; private IChartService chartService { get; set; } = null!;
protected override Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
config = new Config config = new Config
{ {
@ -44,7 +44,7 @@ public partial class SpendingsOverTime : ComponentBase
}; };
var dataSeries = chartService.GetExpensesPerMonth(); var dataSeries = await chartService.GetExpensesPerMonthAsync();
foreach (var dataSerie in dataSeries) foreach (var dataSerie in dataSeries)
{ {
data.Add(new Bar data.Add(new Bar
@ -55,6 +55,6 @@ public partial class SpendingsOverTime : ComponentBase
}); });
}; };
return base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
} }

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -16,10 +16,10 @@ public partial class Classes : ComponentBase
private IQueryable<SubClass>? subclassQueryable; private IQueryable<SubClass>? subclassQueryable;
protected override Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable(); subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
return base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
private async Task AddAsync() private async Task AddAsync()
@ -36,13 +36,13 @@ public partial class Classes : ComponentBase
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled && result.Data != null) if (!result.Cancelled && result.Data != null)
{ {
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable(); subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
} }
} }
private async Task EditAsync(string id) private async Task EditAsync(string id)
{ {
var data = subclassRepository.GetSubClass(id); var data = await subclassRepository.GetSubClassAsync(id);
var dialog = await dialogService.ShowDialogAsync<ClassDialog>(data, new DialogParameters() var dialog = await dialogService.ShowDialogAsync<ClassDialog>(data, new DialogParameters()
{ {
Height = "500px", Height = "500px",
@ -54,19 +54,19 @@ public partial class Classes : ComponentBase
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled && result.Data != null) if (!result.Cancelled && result.Data != null)
{ {
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable(); subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
} }
} }
private async Task DeleteAsync(string id) private async Task DeleteAsync(string id)
{ {
var subclass = subclassRepository.GetSubClass(id); var subclass = await subclassRepository.GetSubClassAsync(id);
var dialog = await dialogService.ShowConfirmationAsync($"Klasse '{subclass.Name}' wirklich löschen?", "Ja", "Nein", "Klasse löschen"); var dialog = await dialogService.ShowConfirmationAsync($"Klasse '{subclass.Name}' wirklich löschen?", "Ja", "Nein", "Klasse löschen");
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled) if (!result.Cancelled)
{ {
subclassRepository.DeleteSubClass(id); await subclassRepository.DeleteSubClassAsync(id);
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable(); subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
} }
} }
} }

View file

@ -28,7 +28,7 @@ public partial class AccountDialog : ComponentBase
{ {
if (editContext.Validate()) if (editContext.Validate())
{ {
accountRepository.SaveAccount(Content); await accountRepository.SaveAccountAsync(Content);
await Dialog.CloseAsync(Content); await Dialog.CloseAsync(Content);
} }
} }

View file

@ -12,6 +12,12 @@
</FluentDialogHeader> </FluentDialogHeader>
<FluentDialogBody> <FluentDialogBody>
@if (editContext is null)
{
<p>Lädt…</p>
return;
}
<EditForm EditContext="@editContext"> <EditForm EditContext="@editContext">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<FluentTextField @bind-Value="@Content.LookupText" Label="Suchtext" Style="width: 300px;"/> <FluentTextField @bind-Value="@Content.LookupText" Label="Suchtext" Style="width: 300px;"/>

View file

@ -8,7 +8,7 @@ namespace Schaad.Accounting.UI.Components.Pages.Dialogs;
public partial class BookingRuleDialog : ComponentBase public partial class BookingRuleDialog : ComponentBase
{ {
private EditContext editContext = null!; private EditContext? editContext;
[CascadingParameter] [CascadingParameter]
public FluentDialog Dialog { get; set; } = null!; public FluentDialog Dialog { get; set; } = null!;
@ -24,17 +24,17 @@ public partial class BookingRuleDialog : ComponentBase
private IReadOnlyList<Account> accounts = []; private IReadOnlyList<Account> accounts = [];
protected override void OnInitialized() protected override async Task OnInitializedAsync()
{ {
editContext = new EditContext(Content); editContext = new EditContext(Content);
accounts = viewService.GetAccountViewList(); accounts = await viewService.GetAccountViewListAsync();
} }
private async Task SaveAsync() private async Task SaveAsync()
{ {
if (editContext.Validate()) if (editContext.Validate())
{ {
bookingRuleRepository.SaveBookingRule(Content); await bookingRuleRepository.SaveBookingRuleAsync(Content);
await Dialog.CloseAsync(Content); await Dialog.CloseAsync(Content);
} }
} }

View file

@ -28,7 +28,7 @@ public partial class BookingTextDialog : ComponentBase
{ {
if (editContext.Validate()) if (editContext.Validate())
{ {
bookingTextRepository.SaveBookingText(Content); await bookingTextRepository.SaveBookingTextAsync(Content);
await Dialog.CloseAsync(Content); await Dialog.CloseAsync(Content);
} }
} }

View file

@ -28,7 +28,7 @@ public partial class ClassDialog : ComponentBase
{ {
if (editContext.Validate()) if (editContext.Validate())
{ {
bookingRuleRepository.SaveSubClass(Content); await bookingRuleRepository.SaveSubClassAsync(Content);
await Dialog.CloseAsync(Content); await Dialog.CloseAsync(Content);
} }
} }

View file

@ -11,6 +11,12 @@
</FluentDialogHeader> </FluentDialogHeader>
<FluentDialogBody> <FluentDialogBody>
@if (editContext is null)
{
<p>Lädt…</p>
return;
}
<EditForm EditContext="@editContext"> <EditForm EditContext="@editContext">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<FluentDatePicker @bind-Value="@SelectedValue" Label="Valuta" Style="width: 300px;"/> <FluentDatePicker @bind-Value="@SelectedValue" Label="Valuta" Style="width: 300px;"/>

View file

@ -8,7 +8,7 @@ namespace Schaad.Accounting.UI.Components.Pages.Dialogs;
public partial class TransactionDialog : ComponentBase public partial class TransactionDialog : ComponentBase
{ {
private EditContext editContext = null!; private EditContext? editContext;
[CascadingParameter] [CascadingParameter]
public FluentDialog Dialog { get; set; } = null!; public FluentDialog Dialog { get; set; } = null!;
@ -25,11 +25,11 @@ public partial class TransactionDialog : ComponentBase
private IReadOnlyList<Account> accounts = []; private IReadOnlyList<Account> accounts = [];
private DateTime? SelectedValue; private DateTime? SelectedValue;
protected override void OnInitialized() protected override async Task OnInitializedAsync()
{ {
SelectedValue = Content.ValueDate; SelectedValue = Content.ValueDate;
editContext = new EditContext(Content); editContext = new EditContext(Content);
accounts = viewService.GetAccountViewList(); accounts = await viewService.GetAccountViewListAsync();
} }
private async Task SaveAsync() private async Task SaveAsync()
@ -38,7 +38,7 @@ public partial class TransactionDialog : ComponentBase
{ {
Content.ValueDate = SelectedValue!.Value; Content.ValueDate = SelectedValue!.Value;
Content.BookingDate = SelectedValue!.Value; Content.BookingDate = SelectedValue!.Value;
transactionRepository.SaveTransaction(Content); await transactionRepository.SaveTransactionAsync(Content);
await Dialog.CloseAsync(Content); await Dialog.CloseAsync(Content);
} }
} }

View file

@ -11,6 +11,12 @@
</FluentDialogHeader> </FluentDialogHeader>
<FluentDialogBody> <FluentDialogBody>
@if (editContext is null)
{
<p>Lädt…</p>
return;
}
Total: @Math.Abs(Content.Value) / Rest: @openAmount Total: @Math.Abs(Content.Value) / Rest: @openAmount
<FluentButton Appearance="Appearance.Neutral" IconEnd="@(new Icons.Regular.Size24.Add())" <FluentButton Appearance="Appearance.Neutral" IconEnd="@(new Icons.Regular.Size24.Add())"
OnClick="@AddTransactionAsync"> OnClick="@AddTransactionAsync">

View file

@ -9,7 +9,7 @@ namespace Schaad.Accounting.UI.Components.Pages.Dialogs;
public partial class TransactionSplitDialog : ComponentBase public partial class TransactionSplitDialog : ComponentBase
{ {
private EditContext editContext = null!; private EditContext? editContext;
[CascadingParameter] [CascadingParameter]
public FluentDialog Dialog { get; set; } = null!; public FluentDialog Dialog { get; set; } = null!;
@ -33,17 +33,17 @@ public partial class TransactionSplitDialog : ComponentBase
private IViewService viewService { get; set; } = null!; private IViewService viewService { get; set; } = null!;
private List<Transaction> transactionList = new(); private List<Transaction> transactionList = new();
private IReadOnlyList<AccountDataset> accounts = null!; private IReadOnlyList<AccountDataset> accounts = [];
private IReadOnlyList<string> bookingTexts = []; private IReadOnlyList<string> bookingTexts = [];
private decimal openAmount = 0; private decimal openAmount = 0;
protected override void OnInitialized() protected override async Task OnInitializedAsync()
{ {
accounts = viewService.GetAccountViewList(); accounts = await viewService.GetAccountViewListAsync();
bookingTexts = bookingTextRepository.GetBookingTextList().Select(b => b.Text).ToArray(); bookingTexts = (await bookingTextRepository.GetBookingTextListAsync()).Select(b => b.Text).ToArray();
var accountList = accountRepository.GetAccountList(); var accountList = await accountRepository.GetAccountListAsync();
var bankTrx = bankTransactionRepository.GetBankTransaction(Content.BankTransactionId); var bankTrx = await bankTransactionRepository.GetBankTransactionAsync(Content.BankTransactionId);
var trx = new Transaction(bankTrx, accountList); var trx = new Transaction(bankTrx, accountList);
trx.Value = Math.Abs(bankTrx.Value); trx.Value = Math.Abs(bankTrx.Value);
transactionList.Add(trx); transactionList.Add(trx);
@ -74,7 +74,7 @@ public partial class TransactionSplitDialog : ComponentBase
{ {
foreach (var transtaction in transactionList) foreach (var transtaction in transactionList)
{ {
transactionRepository.SaveTransaction(transtaction); await transactionRepository.SaveTransactionAsync(transtaction);
} }
await Dialog.CloseAsync(Content); await Dialog.CloseAsync(Content);
} }

View file

@ -44,12 +44,12 @@ public partial class Home : ComponentBase
bool IsCanceled; bool IsCanceled;
Dictionary<int, string> Files = new(); Dictionary<int, string> Files = new();
protected override Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
matchingBankTransactions = viewService.MatchOpenBankTransactions(); matchingBankTransactions = await viewService.MatchOpenBankTransactionsAsync();
accounts = viewService.GetAccountViewList(); accounts = await viewService.GetAccountViewListAsync();
bookingTexts = bookingTextRepository.GetBookingTextList().Select(b => b.Text).ToArray(); bookingTexts = (await bookingTextRepository.GetBookingTextListAsync()).Select(b => b.Text).ToArray();
return base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
async Task OnProgressChangeAsync(FluentInputFileEventArgs file) async Task OnProgressChangeAsync(FluentInputFileEventArgs file)
@ -96,12 +96,12 @@ public partial class Home : ComponentBase
File.Delete(file.Value); File.Delete(file.Value);
} }
matchingBankTransactions = viewService.MatchOpenBankTransactions(); matchingBankTransactions = await viewService.MatchOpenBankTransactionsAsync();
} }
private async Task ImportXmlAndShowResultAsync(string fileName) private async Task ImportXmlAndShowResultAsync(string fileName)
{ {
var messages = fileService.ImportAccountStatementFile(fileName); var messages = await fileService.ImportAccountStatementFileAsync(fileName);
foreach (var message in messages) foreach (var message in messages)
{ {
await ShowImportResultAsync(message); await ShowImportResultAsync(message);
@ -156,9 +156,8 @@ public partial class Home : ComponentBase
ShowToast("Transaktion kann wegen fehlenden Daten nicht gespeichert werden!", ToastIntent.Error); ShowToast("Transaktion kann wegen fehlenden Daten nicht gespeichert werden!", ToastIntent.Error);
return; return;
} }
transactionRepository.SaveTransaction(transaction); await transactionRepository.SaveTransactionAsync(transaction);
transaction.ReadOnly = true; transaction.ReadOnly = true;
await Task.CompletedTask;
} }
private async Task SplitBankTransactionAsync(Transaction transaction) private async Task SplitBankTransactionAsync(Transaction transaction)

View file

@ -1,5 +1,11 @@
@page "/BalanceReport" @page "/BalanceReport"
@if (balance is null)
{
<p>Lädt…</p>
return;
}
<h3>@header</h3> <h3>@header</h3>
<a href="javascript:window.print()" class="hidePrint">Print</a> <a href="javascript:window.print()" class="hidePrint">Print</a>

View file

@ -12,17 +12,17 @@ public partial class BalanceReport : ComponentBase
[Inject] [Inject]
private ISettingsService settingsService { get; set; } = null!; private ISettingsService settingsService { get; set; } = null!;
private BalanceDataset balance = null!; private BalanceDataset? balance;
private decimal win; private decimal win;
private string header = null!; private string header = "";
private string footer = null!; private string footer = "";
protected override Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
balance = viewService.GetBalanceView(); balance = await viewService.GetBalanceViewAsync();
win = balance.TotalActivaCHF - balance.TotalPassivaCHF; win = balance.TotalActivaCHF - balance.TotalPassivaCHF;
(header, footer) = Report.GetViewDataTitleAndFooter("Bilanz", settingsService); (header, footer) = Report.GetViewDataTitleAndFooter("Bilanz", settingsService);
return base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
} }

View file

@ -1,5 +1,11 @@
@page "/BalanceSheetReport" @page "/BalanceSheetReport"
@if (balanceSheet is null)
{
<p>Lädt…</p>
return;
}
<h3>@header</h3> <h3>@header</h3>
<a href="javascript:window.print()" class="hidePrint">Print</a> <a href="javascript:window.print()" class="hidePrint">Print</a>
<br /> <br />

View file

@ -12,17 +12,17 @@ public partial class BalanceSheetReport : ComponentBase
[Inject] [Inject]
private ISettingsService settingsService { get; set; } = null!; private ISettingsService settingsService { get; set; } = null!;
private BalanceSheetDataset balanceSheet = null!; private BalanceSheetDataset? balanceSheet;
private decimal win; private decimal win;
private string header = null!; private string header = "";
private string footer = null!; private string footer = "";
protected override Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
balanceSheet = viewService.GetBalanceSheetView(settingsService.GetYear()); balanceSheet = await viewService.GetBalanceSheetViewAsync(settingsService.GetYear());
win = balanceSheet.ProfitCHF - balanceSheet.LossCHF; win = balanceSheet.ProfitCHF - balanceSheet.LossCHF;
(header, footer) = Report.GetViewDataTitleAndFooter("Jahresabschluss", settingsService); (header, footer) = Report.GetViewDataTitleAndFooter("Jahresabschluss", settingsService);
return base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
} }

View file

@ -1,5 +1,11 @@
@page "/DetailReport" @page "/DetailReport"
@if (accounts is null || transactions is null)
{
<p>Lädt…</p>
return;
}
<h3>@header</h3> <h3>@header</h3>
<a href="javascript:window.print()" class="hidePrint">Print</a> <a href="javascript:window.print()" class="hidePrint">Print</a>

View file

@ -12,23 +12,23 @@ public partial class DetailReport : ComponentBase
[Inject] [Inject]
private ISettingsService settingsService { get; set; } = null!; private ISettingsService settingsService { get; set; } = null!;
private IReadOnlyList<AccountDataset> accounts = null!; private IReadOnlyList<AccountDataset>? accounts;
private IReadOnlyList<TransactionDataset> transactions = null!; private IReadOnlyList<TransactionDataset>? transactions;
private decimal profit; private decimal profit;
private decimal loss; private decimal loss;
private string header = null!; private string header = "";
private string footer = null!; private string footer = "";
protected override Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
accounts = viewService.GetAccountViewList(); accounts = await viewService.GetAccountViewListAsync();
transactions = viewService.GetTransactionViewList(); transactions = await viewService.GetTransactionViewListAsync();
profit = Math.Abs(accounts.Where(m => m.Class == 3).Sum(m => m.Balance)); profit = Math.Abs(accounts.Where(m => m.Class == ClassIds.Income).Sum(m => m.Balance));
loss = Math.Abs(accounts.Where(m => m.Class == 4).Sum(m => m.Balance)); loss = Math.Abs(accounts.Where(m => m.Class == ClassIds.Expenses).Sum(m => m.Balance));
(header, footer) = Report.GetViewDataTitleAndFooter("Detailaufstellung", settingsService); (header, footer) = Report.GetViewDataTitleAndFooter("Detailaufstellung", settingsService);
return base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
} }

View file

@ -1,5 +1,11 @@
@page "/ProfitLossReport" @page "/ProfitLossReport"
@if (accounts is null)
{
<p>Lädt…</p>
return;
}
<h3>@header</h3> <h3>@header</h3>
<a href="javascript:window.print()" class="hidePrint">Print</a> <a href="javascript:window.print()" class="hidePrint">Print</a>

View file

@ -13,22 +13,22 @@ public partial class ProfitLossReport : ComponentBase
[Inject] [Inject]
private ISettingsService settingsService { get; set; } = null!; private ISettingsService settingsService { get; set; } = null!;
private IReadOnlyList<AccountDataset> accounts = null!; private IReadOnlyList<AccountDataset>? accounts;
private decimal profit; private decimal profit;
private decimal loss; private decimal loss;
private decimal win; private decimal win;
private string header = null!; private string header = "";
private string footer = null!; private string footer = "";
protected override Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
accounts = viewService.GetAccountViewList(); accounts = await viewService.GetAccountViewListAsync();
profit = Math.Abs(accounts.Where(m => m.Class == ClassIds.Income).Sum(m => m.Balance)); profit = Math.Abs(accounts.Where(m => m.Class == ClassIds.Income).Sum(m => m.Balance));
loss = Math.Abs(accounts.Where(m => m.Class == ClassIds.Expenses).Sum(m => m.Balance)); loss = Math.Abs(accounts.Where(m => m.Class == ClassIds.Expenses).Sum(m => m.Balance));
win = profit-loss; win = profit - loss;
(header, footer) = Report.GetViewDataTitleAndFooter("Erfolgsrechnung", settingsService); (header, footer) = Report.GetViewDataTitleAndFooter("Erfolgsrechnung", settingsService);
return base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
} }

View file

@ -10,7 +10,7 @@
<FluentNavGroup Title="@(gr.Key)" Expanded="true"> <FluentNavGroup Title="@(gr.Key)" Expanded="true">
@foreach (var account in gr) @foreach (var account in gr)
{ {
<FluentNavLink OnClick="() => ShowTransactions(account.Id)" Icon="@(new Icons.Regular.Size24.CalendarAgenda())"> <FluentNavLink OnClick="() => ShowTransactionsAsync(account.Id)" Icon="@(new Icons.Regular.Size24.CalendarAgenda())">
<FluentStack Orientation="Orientation.Horizontal"> <FluentStack Orientation="Orientation.Horizontal">
<div> <div>
@account.Number / @account.Name @account.Number / @account.Name
@ -29,36 +29,39 @@
</FluentNavMenu> </FluentNavMenu>
<FluentBodyContent> <FluentBodyContent>
<FluentGrid Justify="JustifyContent.FlexStart"> @if (selectedAccount is not null)
<FluentGridItem xs="4" Class="align-content-center title"> {
@selectedAccount.Name <FluentGrid Justify="JustifyContent.FlexStart">
</FluentGridItem> <FluentGridItem xs="4" Class="align-content-center title">
<FluentGridItem xs="4" Class="align-content-center"> @selectedAccount.Name
Total @Math.Abs(selectedAccount.Balance).ToString("N2") @selectedAccount.Currency in @transactionList?.Count() Transaktionen </FluentGridItem>
</FluentGridItem> <FluentGridItem xs="4" Class="align-content-center">
<FluentGridItem xs="4" Class="align-content-center" Justify="JustifyContent.FlexEnd"> Total @Math.Abs(selectedAccount.Balance).ToString("N2") @selectedAccount.Currency in @transactionList?.Count() Transaktionen
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neue Transaktion hinzufügen</FluentButton> </FluentGridItem>
</FluentGridItem> <FluentGridItem xs="4" Class="align-content-center" Justify="JustifyContent.FlexEnd">
</FluentGrid> <FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neue Transaktion hinzufügen</FluentButton>
</FluentGridItem>
</FluentGrid>
<FluentPaginator State="@pagination" SummaryTemplate="@template"/> <FluentPaginator State="@pagination" SummaryTemplate="@template"/>
<FluentDataGrid Items="@transactionList" Pagination="@pagination" RowSize="DataGridRowSize.Medium"> <FluentDataGrid Items="@transactionList" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
<PropertyColumn Property="@(p => p.BookingDate)" Format="dd.MM.yyyy" Sortable="true" IsDefaultSortColumn="true" InitialSortDirection="SortDirection.Descending"/> <PropertyColumn Property="@(p => p.BookingDate)" Format="dd.MM.yyyy" Sortable="true" IsDefaultSortColumn="true" InitialSortDirection="SortDirection.Descending"/>
<PropertyColumn Property="@(p => p.ValueDate)" Format="dd.MM.yyyy" Sortable="true"/> <PropertyColumn Property="@(p => p.ValueDate)" Format="dd.MM.yyyy" Sortable="true"/>
<PropertyColumn Property="@(p => p.Text)"/> <PropertyColumn Property="@(p => p.Text)"/>
<TemplateColumn Title="Gegenkonto"> <TemplateColumn Title="Gegenkonto">
@GetAccountName(context) @GetAccountName(context)
</TemplateColumn> </TemplateColumn>
<TemplateColumn Title="Betrag" Align="Align.End"> <TemplateColumn Title="Betrag" Align="Align.End">
@context.GetValue(selectedAccount.IsFxAccount).ToString("N2") @selectedAccount.Currency @context.GetValue(selectedAccount.IsFxAccount).ToString("N2") @selectedAccount.Currency
</TemplateColumn> </TemplateColumn>
<TemplateColumn Align="Align.Center" Width="50px"> <TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" OnClick="@(() => DeleteAsync(context.Id))"/> <FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" OnClick="@(() => DeleteAsync(context.Id))"/>
</TemplateColumn> </TemplateColumn>
<TemplateColumn Align="Align.Center" Width="50px"> <TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" OnClick="@(() => EditAsync(context.Id))"/> <FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" OnClick="@(() => EditAsync(context.Id))"/>
</TemplateColumn> </TemplateColumn>
</FluentDataGrid> </FluentDataGrid>
}
</FluentBodyContent> </FluentBodyContent>
</FluentStack> </FluentStack>

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Datasets; using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
@ -18,24 +18,24 @@ public partial class Transactions : ComponentBase
[Inject] [Inject]
private IDialogService dialogService { get; set; } = null!; private IDialogService dialogService { get; set; } = null!;
private IReadOnlyList<AccountDataset> accounts = null!; private IReadOnlyList<AccountDataset> accounts = [];
private IQueryable<TransactionDataset>? transactionList; private IQueryable<TransactionDataset>? transactionList;
private string selectedAccountId = null!; private string? selectedAccountId;
private AccountDataset selectedAccount = null!; private AccountDataset? selectedAccount;
protected override Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
accounts = viewService.GetAccountViewList(); accounts = await viewService.GetAccountViewListAsync();
ShowTransactions(accounts.First().Id); await ShowTransactionsAsync(accounts.First().Id);
return base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
private void ShowTransactions(string accountId) private async Task ShowTransactionsAsync(string accountId)
{ {
selectedAccountId = accountId; selectedAccountId = accountId;
selectedAccount = accounts.Single(a => a.Id == accountId); selectedAccount = accounts.Single(a => a.Id == accountId);
transactionList = viewService.GetTransactionViewList(accountId).AsQueryable(); transactionList = (await viewService.GetTransactionViewListAsync(accountId)).AsQueryable();
} }
private string GetAccountName(TransactionDataset transaction) private string GetAccountName(TransactionDataset transaction)
@ -68,13 +68,13 @@ public partial class Transactions : ComponentBase
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled && result.Data != null) if (!result.Cancelled && result.Data != null)
{ {
transactionList = viewService.GetTransactionViewList(selectedAccountId).AsQueryable(); transactionList = (await viewService.GetTransactionViewListAsync(selectedAccountId)).AsQueryable();
} }
} }
private async Task EditAsync(string id) private async Task EditAsync(string id)
{ {
var data = transactionRepository.GetTransaction(id); var data = await transactionRepository.GetTransactionAsync(id);
var dialog = await dialogService.ShowDialogAsync<TransactionDialog>(data, new DialogParameters() var dialog = await dialogService.ShowDialogAsync<TransactionDialog>(data, new DialogParameters()
{ {
Height = "500px", Height = "500px",
@ -86,19 +86,19 @@ public partial class Transactions : ComponentBase
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled && result.Data != null) if (!result.Cancelled && result.Data != null)
{ {
transactionList = viewService.GetTransactionViewList(selectedAccountId).AsQueryable(); transactionList = (await viewService.GetTransactionViewListAsync(selectedAccountId)).AsQueryable();
} }
} }
private async Task DeleteAsync(string id) private async Task DeleteAsync(string id)
{ {
var transaction = transactionRepository.GetTransaction(id); var transaction = await transactionRepository.GetTransactionAsync(id);
var dialog = await dialogService.ShowConfirmationAsync($"Transaction '{transaction.Text}' mit Betrag {transaction.Value} wirklich löschen?", "Ja", "Nein", "Transaktion löschen"); var dialog = await dialogService.ShowConfirmationAsync($"Transaction '{transaction.Text}' mit Betrag {transaction.Value} wirklich löschen?", "Ja", "Nein", "Transaktion löschen");
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled) if (!result.Cancelled)
{ {
transactionRepository.DeleteTransaction(id); await transactionRepository.DeleteTransactionAsync(id);
transactionList = viewService.GetTransactionViewList(selectedAccountId).AsQueryable(); transactionList = (await viewService.GetTransactionViewListAsync(selectedAccountId)).AsQueryable();
} }
} }
} }