Compare commits
No commits in common. "d64b3cd876c181da26a7d2cfb8ce4c37fd2377e0" and "786fafefa56b5cacb359518698366152799ebb34" have entirely different histories.
d64b3cd876
...
786fafefa5
63 changed files with 756 additions and 1081 deletions
|
|
@ -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)** — landed as PR P after being explicitly requested. See PR P entry below.
|
- **Item 19 (async I/O)** — deferred. After PR D, each XML file is loaded at most once per SignalR circuit, and this app is single-user local Blazor Server. Converting every repository/service method to async would touch ~40 files for negligible user-visible benefit and real regression risk. Revisit if the app is ever hosted for multiple concurrent users.
|
||||||
- **PR G** — Phase 4 items 23 + 24: `Schaad.Accounting.Tests` xUnit + NSubstitute + Shouldly project. Adopts the `xxxTestShould.DoThisWhenThat` naming convention with Shouldly assertions (no xUnit `Assert.*`). Covers `ViewService`, `TransactionRepository`, `Formatting`, `RepositoryCache`, `AccountRepository`, `ChartService`, and `FileService.GetTransactionListCsv` — 38 tests locking in the earlier PRs' behavior. Note: originally shipped as three separate commits (initial project, expanded coverage, Shouldly + naming conversion) and later squashed into one commit at the user's request.
|
- **PR G** — Phase 4 items 23 + 24: `Schaad.Accounting.Tests` xUnit + NSubstitute + Shouldly project. Adopts the `xxxTestShould.DoThisWhenThat` naming convention with Shouldly assertions (no xUnit `Assert.*`). Covers `ViewService`, `TransactionRepository`, `Formatting`, `RepositoryCache`, `AccountRepository`, `ChartService`, and `FileService.GetTransactionListCsv` — 38 tests locking in the earlier PRs' behavior. Note: originally shipped as three separate commits (initial project, expanded coverage, Shouldly + naming conversion) and later squashed into one commit at the user's request.
|
||||||
- **PR H** — Phase 2 item 18 (first slice): add `ILogger<FileService>` to `FileService.ImportAccountStatementFile` so bank-statement imports emit `Information` for the file being processed and each account's import count, `Warning` when an account is skipped because it belongs to a different mandator, and `Error` when the vendor parser reports a failure. Rest of the logging (BaseRepository save failures, MatchOpenBankTransactions summary) tracked as a follow-up because it requires threading loggers through all seven repositories.
|
- **PR 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.
|
||||||
|
|
@ -110,5 +110,3 @@ Analysis and phased plan produced 2026-07-02. See conversation history for full
|
||||||
- **PR L** — Phase 2 item 10: drop the hand-rolled `Copy(target)` methods from `Account`, `BookingRule`, `BookingText`, `SubClass`, and `SplitPredefinition`; keep only a `Transaction.Clone()` for defensive-copy needs in `GetTransaction` / `ViewService.WithDisplaySign`. Every `SaveXxx` now uses `FindIndex` → in-place replace (or `Add` for new entries), moving the two "hidden" defaults (`Account.Currency = "CHF"` when unset, `Transaction.BookingDate = ValueDate` when unset) into the corresponding `SaveXxx` method where they belong. Fixes a pre-existing bug: `Transaction.Copy` never copied `RelatedParty`, so update-saves silently dropped it. Added `PreserveRelatedPartyWhenRoundTrippingTransaction` and `DefaultBookingDateToValueDateWhenBookingDateIsUnset` as regression tests. 42 tests total.
|
- **PR L** — Phase 2 item 10: drop the hand-rolled `Copy(target)` methods from `Account`, `BookingRule`, `BookingText`, `SubClass`, and `SplitPredefinition`; keep only a `Transaction.Clone()` for defensive-copy needs in `GetTransaction` / `ViewService.WithDisplaySign`. Every `SaveXxx` now uses `FindIndex` → in-place replace (or `Add` for new entries), moving the two "hidden" defaults (`Account.Currency = "CHF"` when unset, `Transaction.BookingDate = ValueDate` when unset) into the corresponding `SaveXxx` method where they belong. Fixes a pre-existing bug: `Transaction.Copy` never copied `RelatedParty`, so update-saves silently dropped it. Added `PreserveRelatedPartyWhenRoundTrippingTransaction` and `DefaultBookingDateToValueDateWhenBookingDateIsUnset` as regression tests. 42 tests total.
|
||||||
- **PR M** — Phase 2 item 18 (final slice): add `ILogger<ViewService>` and log a match-count summary from `MatchOpenBankTransactions` (`"Matched {Matched} of {Total} open bank transactions"`). Completes item 18 — statement imports (PR H), repository save failures (PR J), and match runs now all emit structured logs. Also removes a dead `ISettingsService` injection from `Home.razor.cs` (declared with `[Inject]` but never used anywhere in the file).
|
- **PR 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 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.
|
|
||||||
|
|
|
||||||
354
MCP_DESIGN.md
354
MCP_DESIGN.md
|
|
@ -1,354 +0,0 @@
|
||||||
# MCP server design
|
|
||||||
|
|
||||||
Design produced 2026-07-03 in conversation with Claude. Read alongside `IMPROVEMENT_PLAN.md`.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Let Claude answer questions like *"how much did I spend on groceries in Q1?"*, *"what's my current balance in CHF?"*, *"which recurring debits changed vs. last year?"*.
|
|
||||||
- **Read-only.** No `Save*`, `Delete*`, `Backup`, or `Import*` tools ever exposed.
|
|
||||||
- Reuse the async `ViewService` / repository stack already in the codebase. No parallel data layer.
|
|
||||||
- Local stdio transport. No HTTP, no auth surface. The MCP server process runs on your machine, but any field a tool returns is forwarded by the MCP client to whichever model it's paired with. See [Privacy modes](#privacy-modes) below for what that actually means in practice.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Transport
|
|
||||||
|
|
||||||
- **Stdio JSON-RPC** via the official `ModelContextProtocol` .NET SDK (Microsoft org's MCP package). Console app, `Microsoft.Extensions.Hosting`-based, `[McpServerToolType]` + `[McpServerTool]` attributes.
|
|
||||||
- No custom framing/serialization — the SDK handles it.
|
|
||||||
|
|
||||||
### Project layout
|
|
||||||
|
|
||||||
```
|
|
||||||
Schaad.Accounting.Mcp/
|
|
||||||
├── Schaad.Accounting.Mcp.csproj - net9.0 console
|
|
||||||
├── Program.cs - host + DI + McpServer.CreateStdioServer
|
|
||||||
├── Context/
|
|
||||||
│ └── AccountingScope.cs - year/mandator switching (see below)
|
|
||||||
├── Dtos/
|
|
||||||
│ ├── AccountDto.cs - flat records for LLM consumption
|
|
||||||
│ ├── TransactionDto.cs
|
|
||||||
│ └── ...
|
|
||||||
└── Tools/
|
|
||||||
├── ContextTools.cs - list_mandators, list_years
|
|
||||||
├── AccountTools.cs - list_accounts, get_account
|
|
||||||
├── BalanceTools.cs - get_balance_summary, get_income_statement
|
|
||||||
├── TransactionTools.cs - get_transactions, search_transactions
|
|
||||||
└── AnalysisTools.cs - get_expenses_by_category, compare_years
|
|
||||||
```
|
|
||||||
|
|
||||||
### DI wiring
|
|
||||||
|
|
||||||
The composition root `AddAccounting()` currently lives in `Schaad.Accounting.UI/Extensions.cs`. Two options:
|
|
||||||
|
|
||||||
1. **Extract** to a new tiny `Schaad.Accounting.Composition` project that references Db + Services. UI and Mcp both reference it.
|
|
||||||
2. **Duplicate** the ~15-line registration block in `Mcp/Program.cs`.
|
|
||||||
|
|
||||||
Recommend **(1)** — with two composition roots, every subsequent DI change would otherwise need to touch both.
|
|
||||||
|
|
||||||
`Mcp/Program.cs` is then:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
|
||||||
builder.Services.Configure<SettingsDataset>(builder.Configuration.GetSection("Settings"));
|
|
||||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<SettingsDataset>>().Value);
|
|
||||||
builder.Services.AddAccounting();
|
|
||||||
|
|
||||||
builder.Services
|
|
||||||
.AddMcpServer()
|
|
||||||
.WithStdioServerTransport()
|
|
||||||
.WithToolsFromAssembly();
|
|
||||||
|
|
||||||
await builder.Build().RunAsync();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Handling year/mandator
|
|
||||||
|
|
||||||
`SettingsService` is `Singleton` and holds `year` + `mandator`. The UI mutates them via `MyHeader` + `forceLoad`. There is no equivalent in stdio MCP.
|
|
||||||
|
|
||||||
**Approach: every tool takes optional `year` and `mandator`; wrap execution in a scope that stashes & restores.**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public sealed class AccountingScope : IDisposable
|
|
||||||
{
|
|
||||||
private readonly ISettingsService settings;
|
|
||||||
private readonly int oldYear;
|
|
||||||
private readonly string oldMandator;
|
|
||||||
|
|
||||||
public AccountingScope(ISettingsService settings, int? year, string? mandator)
|
|
||||||
{
|
|
||||||
this.settings = settings;
|
|
||||||
oldYear = settings.GetYear();
|
|
||||||
oldMandator = settings.GetMandator();
|
|
||||||
if (year.HasValue) settings.SetYear(year.Value);
|
|
||||||
if (mandator is not null) settings.SetMandator(mandator);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
settings.SetYear(oldYear);
|
|
||||||
settings.SetMandator(oldMandator);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Every tool wraps its body:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[McpServerTool, Description("...")]
|
|
||||||
public async Task<...> ListAccountsAsync(int? year = null, string? mandator = null, ...)
|
|
||||||
{
|
|
||||||
using var _ = new AccountingScope(settingsService, year, mandator);
|
|
||||||
// RepositoryCache is Scoped; resolve a fresh IServiceScope so scoped services
|
|
||||||
// don't hold references to entries from an earlier (year, mandator).
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Concurrency**: stdio MCP calls are serial (one JSON-RPC message at a time), so the Singleton mutation window is safe. If we ever move to HTTP transport with concurrent calls, this design breaks — flag that in code.
|
|
||||||
|
|
||||||
**Cache freshness**: `RepositoryCache` is `Scoped` and keyed by absolute file path (which includes `dbPath` → year+mandator). When the scope changes year/mandator, the cache keys naturally change too, so stale data is not returned. But we should still resolve a fresh `IServiceScope` per tool call:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class ToolExecutor
|
|
||||||
{
|
|
||||||
private readonly IServiceScopeFactory scopeFactory;
|
|
||||||
private readonly ISettingsService settings;
|
|
||||||
|
|
||||||
public async Task<T> Run<T>(int? year, string? mandator, Func<IServiceProvider, Task<T>> body)
|
|
||||||
{
|
|
||||||
using var _ = new AccountingScope(settings, year, mandator);
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
|
||||||
return await body(scope.ServiceProvider);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Each tool class takes `ToolExecutor` and wraps its body.
|
|
||||||
|
|
||||||
## Tool catalog
|
|
||||||
|
|
||||||
Naming: `snake_case`. All optional args default sensibly. All amounts are `decimal`, CHF-normalized where a currency isn't specified. All dates are ISO 8601 (`yyyy-MM-dd`).
|
|
||||||
|
|
||||||
### `list_mandators`
|
|
||||||
|
|
||||||
> List the mandators (client accounting sets) that have data.
|
|
||||||
|
|
||||||
- **Input:** `{ year?: int }`
|
|
||||||
- **Output:** `{ mandators: string[] }`
|
|
||||||
|
|
||||||
### `list_years`
|
|
||||||
|
|
||||||
> List the years for which the given mandator has data.
|
|
||||||
|
|
||||||
- **Input:** `{ mandator?: string }`
|
|
||||||
- **Output:** `{ years: int[] }`
|
|
||||||
|
|
||||||
### `list_accounts`
|
|
||||||
|
|
||||||
> List all accounts with their current-year balance in both account currency and CHF. Use before `get_transactions` to look up account IDs.
|
|
||||||
|
|
||||||
- **Input:** `{ year?, mandator?, class?: "activa"|"passiva"|"income"|"expenses" }`
|
|
||||||
- **Output:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"accounts": [
|
|
||||||
{ "id": "...", "number": 1010, "name": "Checking", "currency": "CHF",
|
|
||||||
"class": "activa", "subclassName": "Umlaufvermögen",
|
|
||||||
"balance": 12345.67, "balanceChf": 12345.67 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `get_balance_summary`
|
|
||||||
|
|
||||||
> Total activa vs. total passiva, plus equity (activa − passiva). CHF.
|
|
||||||
|
|
||||||
- **Input:** `{ year?, mandator? }`
|
|
||||||
- **Output:** `{ totalActivaChf, totalPassivaChf, equityChf }`
|
|
||||||
|
|
||||||
### `get_income_statement`
|
|
||||||
|
|
||||||
> Income statement (Erfolgsrechnung) for the year: profit, loss, net, and the account list per side.
|
|
||||||
|
|
||||||
- **Input:** `{ year?, mandator? }`
|
|
||||||
- **Output:** `{ profitChf, lossChf, netChf, incomeAccounts: [...], expenseAccounts: [...] }`
|
|
||||||
|
|
||||||
### `get_transactions`
|
|
||||||
|
|
||||||
> Fetch transactions with filters. Prefer narrow filters — this is the main workhorse. Truncated at `limit` (default 200, max 1000).
|
|
||||||
|
|
||||||
- **Input:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"year": 2026,
|
|
||||||
"mandator": null,
|
|
||||||
"accountId": null,
|
|
||||||
"fromDate": "2026-01-01",
|
|
||||||
"toDate": "2026-03-31",
|
|
||||||
"textSearch": "coop",
|
|
||||||
"minAmount": 100,
|
|
||||||
"maxAmount": null,
|
|
||||||
"limit": 200
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- **Output:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"transactions": [
|
|
||||||
{ "id": "...", "date": "2026-02-14", "bookingDate": "2026-02-14",
|
|
||||||
"text": "Coop City", "value": 87.30, "currency": "CHF",
|
|
||||||
"originAccountId": "chk", "originAccountName": "Checking",
|
|
||||||
"targetAccountId": "groc", "targetAccountName": "Groceries",
|
|
||||||
"relatedParty": "Coop Genossenschaft" }
|
|
||||||
],
|
|
||||||
"total": 47,
|
|
||||||
"truncated": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Backed by `ITransactionRepository.GetTransactionListAsync()` + in-memory filter — datasets are small enough that we don't need repo-level query pushdown.
|
|
||||||
|
|
||||||
### `get_expenses_by_category`
|
|
||||||
|
|
||||||
> Expenses grouped by subclass or account. Sorted descending by CHF total.
|
|
||||||
|
|
||||||
- **Input:** `{ year?, mandator?, groupBy?: "subclass" | "account" }`
|
|
||||||
- **Output:** `{ categories: [{ name, totalChf, transactionCount }] }`
|
|
||||||
|
|
||||||
### `compare_years`
|
|
||||||
|
|
||||||
> Side-by-side comparison for equivalent categories across years. Useful for *"how did my spending change YoY"*.
|
|
||||||
|
|
||||||
- **Input:** `{ years: int[], mandator?, side: "income" | "expenses" | "both" }`
|
|
||||||
- **Output:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"comparison": [
|
|
||||||
{ "year": 2025, "totalIncomeChf": ..., "totalExpensesChf": ...,
|
|
||||||
"byCategory": { "Groceries": 4800.50, "Rent": 24000, ... } },
|
|
||||||
{ "year": 2026, ... }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Output shape conventions
|
|
||||||
|
|
||||||
- **Flat records**: no nested navigation properties. Account name inlined next to id so the LLM doesn't need a follow-up call.
|
|
||||||
- **CHF-normalized**: any total is always in CHF. Per-transaction values keep the account currency plus the CHF equivalent when accounts differ.
|
|
||||||
- **No booleans for "successful"** — either the tool returns data or it errors. MCP has native error surfacing.
|
|
||||||
- **Cap collection sizes**: `get_transactions` truncates at `limit`; the LLM decides whether to narrow the filter.
|
|
||||||
|
|
||||||
## Read-only enforcement
|
|
||||||
|
|
||||||
- Repository / service **interfaces** still expose write methods (they're used from the UI). What's exposed is controlled by simply not authoring a `[McpServerTool]` for those verbs.
|
|
||||||
- To make this explicit, the `Tools/` folder contains **only read operations**, and typed errors (or similar) are the only way the LLM sees "no such account".
|
|
||||||
- **Do NOT expose `SetYear` / `SetMandator` as tools.** Year/mandator switching happens implicitly per-call via `AccountingScope`. Otherwise the LLM could clobber the UI's session state.
|
|
||||||
|
|
||||||
## Packaging & Claude Desktop
|
|
||||||
|
|
||||||
Build once:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet publish Schaad.Accounting.Mcp/ -c Release -o D:/AccountingMcp
|
|
||||||
```
|
|
||||||
|
|
||||||
Claude Desktop `claude_desktop_config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"schaad-accounting": {
|
|
||||||
"command": "D:/AccountingMcp/Schaad.Accounting.Mcp.exe",
|
|
||||||
"env": {
|
|
||||||
"Settings__DataPath": "D:/Developer/AccountingData/",
|
|
||||||
"Settings__DefaultMandator": "Claudio Schaad"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`FixerIoApiKey` doesn't need to be set — MCP tools only ever need CHF conversions of stored CHF values, and `DummyFxService` is the current implementation.
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
- **Local stdio only.** No listening port. Anything running as your user can already read the XML files; MCP doesn't widen the local-attack blast radius.
|
|
||||||
- **`DataPath` only from environment / config.** Never accept `set_data_path` or similar. The LLM must not be able to point the server at arbitrary directories.
|
|
||||||
- **No write tools** — repeated deliberately because it's the single biggest risk mitigation.
|
|
||||||
- No secrets in tool outputs. The `FixerIoApiKey` is not returned by any tool.
|
|
||||||
- Data exposure to the model provider is a separate axis from local security. See [Privacy modes](#privacy-modes) below.
|
|
||||||
|
|
||||||
## Privacy modes
|
|
||||||
|
|
||||||
**The data-flow reality.** When Claude Desktop calls an MCP tool, the invocation and result are relayed through the Anthropic API so the model can reason over them. "Local stdio" only means the *server process* runs on your PC — the JSON payload it emits still crosses the network on its way to the model. Other MCP clients (Cursor, Zed, any hosted-model client) work the same way.
|
|
||||||
|
|
||||||
To make the trade-off explicit, the server supports three privacy modes via a `Mcp:Privacy` config key.
|
|
||||||
|
|
||||||
### `Full` (default)
|
|
||||||
|
|
||||||
Every tool from the catalog is registered. Tool results include per-transaction detail: text, related party, amount, dates, source/target account names. Most useful for open-ended questions, most exposing — a single `get_transactions` call can send hundreds of rows of counterparty/amount data to the model provider.
|
|
||||||
|
|
||||||
**Use when:** you're comfortable with the model provider's data-use policy on your account, and you want maximum answer quality.
|
|
||||||
|
|
||||||
### `Aggregate`
|
|
||||||
|
|
||||||
Only aggregate tools are registered:
|
|
||||||
|
|
||||||
- `list_accounts` — trimmed: name, currency, class, `balanceChf`. **No account IDs, no bank account numbers, no start balance.**
|
|
||||||
- `get_balance_summary`
|
|
||||||
- `get_income_statement` — trimmed: totals per class, no per-account rows.
|
|
||||||
- `get_expenses_by_category` — grouped by subclass name.
|
|
||||||
- `compare_years` — same as `Full`.
|
|
||||||
|
|
||||||
Withheld: `get_transactions` (entirely), per-account balance rows, related-party names, individual dates and texts.
|
|
||||||
|
|
||||||
**Use when:** you want spending/income insight without exposing individual counterparties or transaction narratives. Trades power for privacy — Claude can answer *"how did my grocery spending change YoY?"* but not *"what was that odd 850 CHF withdrawal in March?"*.
|
|
||||||
|
|
||||||
### `Local`
|
|
||||||
|
|
||||||
Same tool catalog as `Full`, but intended to be paired with a **local** MCP client — e.g. an Ollama- or LM-Studio-backed client that runs the model on your own machine. The MCP server itself doesn't and can't enforce this; the choice happens in which client you point at the server binary. Nothing crosses the network. Answer quality is lower than Claude, sometimes considerably.
|
|
||||||
|
|
||||||
**Use when:** you don't want any financial data leaving your PC at all.
|
|
||||||
|
|
||||||
### Configuring the mode
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"schaad-accounting": {
|
|
||||||
"command": "D:/AccountingMcp/Schaad.Accounting.Mcp.exe",
|
|
||||||
"env": {
|
|
||||||
"Settings__DataPath": "D:/Developer/AccountingData/",
|
|
||||||
"Settings__DefaultMandator": "Claudio Schaad",
|
|
||||||
"Mcp__Privacy": "Aggregate"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`Program.cs` reads `Mcp:Privacy` at startup and decides which `[McpServerToolType]` classes to register with the SDK. Modes are enforced by *registration*, not by runtime filtering — a tool that's not registered simply doesn't exist from the model's point of view, so the model can't accidentally call it.
|
|
||||||
|
|
||||||
For belt-and-braces defence-in-depth, the sensitive-output DTOs (`TransactionDto`, per-account rows) can also live in a separate namespace whose types are only ever *constructed* by tools that are registered under `Full`; that way an accidentally shipped aggregate build won't compile against them.
|
|
||||||
|
|
||||||
## Out of scope for v1
|
|
||||||
|
|
||||||
- **Resources**: expose the chart of accounts as an MCP `Resource` so the LLM reads structure once instead of via `list_accounts` on every conversation.
|
|
||||||
- **Prompts**: prebuilt Claude prompts (*"Monatlicher Kassenbericht"*, *"Erklär mir das Q3-Wachstum"*).
|
|
||||||
- **Bank-transaction matching preview**: expose the `MatchOpenBankTransactions` output as a read-only tool so Claude can review the auto-matcher's guesses.
|
|
||||||
- **Budgets / alerts**: not modelled in the app yet, so no data to expose.
|
|
||||||
|
|
||||||
## Rough implementation size
|
|
||||||
|
|
||||||
- New project + csproj + Program.cs: ~40 LOC
|
|
||||||
- `AccountingScope` + `ToolExecutor`: ~40 LOC
|
|
||||||
- 8 tools with DTOs: ~350 LOC (mostly declarative attribute schemas + `Select` projections)
|
|
||||||
- Extract `AddAccounting()` to `Composition` project: ~30 LOC (mostly moving)
|
|
||||||
- **Total: ~450 LOC**, plus config/docs.
|
|
||||||
|
|
||||||
## Suggested PR sequencing
|
|
||||||
|
|
||||||
- **PR Q** — Extract `AddAccounting()` from `Schaad.Accounting.UI/Extensions.cs` to a new `Schaad.Accounting.Composition` project referenced by UI and Mcp. Mechanical, low risk.
|
|
||||||
- **PR R** — Add the `Schaad.Accounting.Mcp` project with `Program.cs`, `AccountingScope`, `ToolExecutor`, and the first two tools (`list_mandators`, `list_accounts`). Enough to wire Claude Desktop and iterate on the actual conversation feel before adding analytics tools.
|
|
||||||
- **PR S** — Balance + income statement tools (`get_balance_summary`, `get_income_statement`).
|
|
||||||
- **PR T** — `get_transactions` (the workhorse) with filter validation and truncation.
|
|
||||||
- **PR U** — Analytics (`get_expenses_by_category`, `compare_years`).
|
|
||||||
|
|
@ -1,10 +1,23 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Schaad.Accounting.Datasets.Reports
|
namespace Schaad.Accounting.Datasets.Reports
|
||||||
{
|
{
|
||||||
public record BalanceDataset(
|
public class BalanceDataset
|
||||||
List<AccountDataset> ActivaAccountList,
|
{
|
||||||
List<AccountDataset> PassivaAccountList,
|
public List<AccountDataset> ActivaAccountList { get; set; }
|
||||||
decimal TotalActivaCHF,
|
|
||||||
decimal TotalPassivaCHF);
|
public List<AccountDataset> PassivaAccountList { get; set; }
|
||||||
|
|
||||||
|
public decimal TotalActivaCHF { get; set; }
|
||||||
|
|
||||||
|
public decimal TotalPassivaCHF { get; set; }
|
||||||
|
|
||||||
|
public BalanceDataset(List<AccountDataset> activaAccountList, List<AccountDataset> passivaAccountList, decimal totalActivaCHF, decimal totalPassivaCHF)
|
||||||
|
{
|
||||||
|
ActivaAccountList = activaAccountList;
|
||||||
|
PassivaAccountList = passivaAccountList;
|
||||||
|
TotalActivaCHF = totalActivaCHF;
|
||||||
|
TotalPassivaCHF = totalPassivaCHF;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,12 +1,29 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Schaad.Accounting.Datasets.Reports
|
namespace Schaad.Accounting.Datasets.Reports
|
||||||
{
|
{
|
||||||
public record BalanceSheetDataset(
|
public class BalanceSheetDataset
|
||||||
List<AccountDataset> ActivaAccountList,
|
{
|
||||||
List<AccountDataset> IncomeAccountList,
|
public List<AccountDataset> ActivaAccountList { get; }
|
||||||
List<AccountDataset> ExpensesAccountList,
|
|
||||||
decimal ProfitCHF,
|
public List<AccountDataset> IncomeAccountList { get; }
|
||||||
decimal LossCHF,
|
|
||||||
int Year);
|
public List<AccountDataset> ExpensesAccountList { get; }
|
||||||
|
|
||||||
|
public decimal ProfitCHF { get; }
|
||||||
|
|
||||||
|
public decimal LossCHF { get; }
|
||||||
|
|
||||||
|
public int Year { get; }
|
||||||
|
|
||||||
|
public BalanceSheetDataset(List<AccountDataset> activaAccountList, List<AccountDataset> incomeAccountList, List<AccountDataset> expensesAccountList, decimal profitCHF, decimal lossCHF, int year)
|
||||||
|
{
|
||||||
|
ActivaAccountList = activaAccountList;
|
||||||
|
IncomeAccountList = incomeAccountList;
|
||||||
|
ExpensesAccountList = expensesAccountList;
|
||||||
|
ProfitCHF = profitCHF;
|
||||||
|
LossCHF = lossCHF;
|
||||||
|
Year = year;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,21 +1,20 @@
|
||||||
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
|
||||||
{
|
{
|
||||||
Task<List<Account>> GetAccountListAsync();
|
List<Account> GetAccountList();
|
||||||
|
|
||||||
Task SaveAccountAsync(Account account);
|
void SaveAccount(Account account);
|
||||||
|
|
||||||
Task<Account> GetAccountAsync(string id);
|
Account GetAccount(string id);
|
||||||
|
|
||||||
Task DeleteAccountAsync(string id);
|
void DeleteAccount(string id);
|
||||||
|
|
||||||
Task<Account> GetAccountForBankAccountNumberAsync(string bankAccountNumber);
|
Account GetAccountForBankAccountNumber(string bankAccountNumber);
|
||||||
|
|
||||||
Task SaveBankAccountBalanceAsync(string bankAccountNumber, decimal accountBalance);
|
void SaveBankAccountBalance(string bankAccountNumber, decimal accountBalance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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;
|
||||||
|
|
||||||
|
|
@ -7,10 +6,10 @@ namespace Schaad.Accounting.Interfaces
|
||||||
{
|
{
|
||||||
public interface IBankTransactionRepository
|
public interface IBankTransactionRepository
|
||||||
{
|
{
|
||||||
Task<List<BankTransaction>> GetBankTransactionListAsync();
|
List<BankTransaction> GetBankTransactionList();
|
||||||
|
|
||||||
Task<int> SaveBankTransactionListAsync(string bankAccountNumber, List<BankTransactionDataset> bankTransactions);
|
int SaveBankTransactionList(string bankAccountNumber, List<BankTransactionDataset> bankTransactions);
|
||||||
|
|
||||||
Task<BankTransaction> GetBankTransactionAsync(string id);
|
BankTransaction GetBankTransaction(string id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
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
|
||||||
{
|
{
|
||||||
Task<List<BookingRule>> GetBookingRuleListAsync();
|
List<BookingRule> GetBookingRuleList();
|
||||||
|
|
||||||
Task SaveBookingRuleAsync(BookingRule bookingRule);
|
void SaveBookingRule(BookingRule bookingRule);
|
||||||
|
|
||||||
Task<BookingRule> GetBookingRuleAsync(string id);
|
BookingRule GetBookingRule(string id);
|
||||||
|
void DeleteBookingRule(string id);
|
||||||
Task DeleteBookingRuleAsync(string id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
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
|
||||||
{
|
{
|
||||||
Task<List<BookingText>> GetBookingTextListAsync();
|
List<BookingText> GetBookingTextList();
|
||||||
|
|
||||||
Task SaveBookingTextAsync(BookingText bookingText);
|
void SaveBookingText(BookingText bookingText);
|
||||||
|
|
||||||
Task<BookingText> GetBookingTextAsync(string id);
|
BookingText GetBookingText(string id);
|
||||||
|
|
||||||
Task DeleteBookingTextAsync(string id);
|
void DeleteBookingText(string id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
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
|
||||||
{
|
{
|
||||||
Task<List<SplitPredefinition>> GetSplitPredefinitionListAsync();
|
List<SplitPredefinition> GetSplitPredefinitionList();
|
||||||
|
|
||||||
Task SaveSplitPredefinitionAsync(SplitPredefinition splitPredefinition);
|
void SaveSplitPredefinition(SplitPredefinition splitPredefinition);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +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 ISubclassRepository
|
public interface ISubclassRepository
|
||||||
{
|
{
|
||||||
Task<List<SubClass>> GetSubClassListAsync();
|
List<SubClass> GetSubClassList();
|
||||||
|
|
||||||
Task SaveSubClassAsync(SubClass subClass);
|
void SaveSubClass(SubClass subClass);
|
||||||
|
|
||||||
Task<SubClass> GetSubClassAsync(string id);
|
SubClass GetSubClass(string id);
|
||||||
|
|
||||||
Task DeleteSubClassAsync(string id);
|
void DeleteSubClass(string id);
|
||||||
|
|
||||||
Dictionary<int, string> GetClasses();
|
Dictionary<int, string> GetClasses();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
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
|
||||||
{
|
{
|
||||||
Task<List<Transaction>> GetTransactionListAsync();
|
List<Transaction> GetTransactionList();
|
||||||
|
|
||||||
Task SaveTransactionAsync(Transaction transaction);
|
void SaveTransaction(Transaction transaction);
|
||||||
|
|
||||||
Task<Transaction> GetTransactionAsync(string id);
|
Transaction GetTransaction(string id);
|
||||||
|
void DeleteTransaction(string id);
|
||||||
Task DeleteTransactionAsync(string id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
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;
|
||||||
|
|
@ -19,16 +18,22 @@ namespace Schaad.Accounting.Repositories
|
||||||
EnsureAccountsFile();
|
EnsureAccountsFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<Account>> GetAccountListAsync() => LoadListAsync<Account>(ACCOUNTS);
|
/// <summary>
|
||||||
|
/// Load accounts
|
||||||
|
/// </summary>
|
||||||
|
public List<Account> GetAccountList() => LoadList<Account>(ACCOUNTS);
|
||||||
|
|
||||||
public async Task SaveAccountAsync(Account account)
|
/// <summary>
|
||||||
|
/// 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 = await GetAccountListAsync();
|
var accounts = GetAccountList();
|
||||||
var idx = accounts.FindIndex(a => a.Id == account.Id);
|
var idx = accounts.FindIndex(a => a.Id == account.Id);
|
||||||
if (idx >= 0)
|
if (idx >= 0)
|
||||||
{
|
{
|
||||||
|
|
@ -39,33 +44,42 @@ namespace Schaad.Accounting.Repositories
|
||||||
account.Id = Guid.NewGuid().ToString();
|
account.Id = Guid.NewGuid().ToString();
|
||||||
accounts.Add(account);
|
accounts.Add(account);
|
||||||
}
|
}
|
||||||
await SaveAsync(accounts, ACCOUNTS);
|
Save(accounts, ACCOUNTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Account> GetAccountAsync(string id)
|
/// <summary>
|
||||||
|
/// Get account
|
||||||
|
/// </summary>
|
||||||
|
public Account GetAccount(string id)
|
||||||
{
|
{
|
||||||
var accounts = await GetAccountListAsync();
|
var accounts = GetAccountList();
|
||||||
return accounts.FirstOrDefault(t => t.Id == id);
|
return accounts.FirstOrDefault(t => t.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAccountAsync(string id)
|
/// <summary>
|
||||||
|
/// Delete account
|
||||||
|
/// </summary>
|
||||||
|
public void DeleteAccount(string id)
|
||||||
{
|
{
|
||||||
var accounts = await GetAccountListAsync();
|
var accounts = GetAccountList();
|
||||||
accounts.RemoveAll(t => t.Id == id);
|
accounts.RemoveAll(t => t.Id == id);
|
||||||
await SaveAsync(accounts, ACCOUNTS);
|
Save(accounts, ACCOUNTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Account> GetAccountForBankAccountNumberAsync(string bankAccountNumber)
|
/// <summary>
|
||||||
|
/// Get account
|
||||||
|
/// </summary>
|
||||||
|
public Account GetAccountForBankAccountNumber(string bankAccountNumber)
|
||||||
{
|
{
|
||||||
var accounts = await GetAccountListAsync();
|
var accounts = GetAccountList();
|
||||||
return accounts.SingleOrDefault(t => t.BankAccountNumber == bankAccountNumber);
|
return accounts.SingleOrDefault(t => t.BankAccountNumber == bankAccountNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SaveBankAccountBalanceAsync(string bankAccountNumber, decimal accountBalance)
|
public void SaveBankAccountBalance(string bankAccountNumber, decimal accountBalance)
|
||||||
{
|
{
|
||||||
var account = await GetAccountForBankAccountNumberAsync(bankAccountNumber);
|
var account = GetAccountForBankAccountNumber(bankAccountNumber);
|
||||||
account.LastBankBalance = accountBalance;
|
account.LastBankBalance = accountBalance;
|
||||||
await SaveAccountAsync(account);
|
SaveAccount(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureAccountsFile()
|
private void EnsureAccountsFile()
|
||||||
|
|
@ -84,9 +98,7 @@ 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.
|
||||||
// Uses the sync GetAwaiter().GetResult() bridge because this runs from the
|
var accounts = GetAccountList();
|
||||||
// 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))
|
||||||
{
|
{
|
||||||
|
|
@ -103,7 +115,7 @@ namespace Schaad.Accounting.Repositories
|
||||||
}
|
}
|
||||||
if (modified)
|
if (modified)
|
||||||
{
|
{
|
||||||
SaveAsync(accounts, ACCOUNTS).GetAwaiter().GetResult();
|
Save(accounts, ACCOUNTS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
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;
|
||||||
|
|
@ -17,15 +16,18 @@ namespace Schaad.Accounting.Repositories
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<BankTransaction>> GetBankTransactionListAsync() => LoadListAsync<BankTransaction>(BANK_TRANSACTIONS);
|
/// <summary>
|
||||||
|
/// 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 async Task<int> SaveBankTransactionListAsync(string bankAccountNumber, List<BankTransactionDataset> bankTransactions)
|
public int SaveBankTransactionList(string bankAccountNumber, List<BankTransactionDataset> bankTransactions)
|
||||||
{
|
{
|
||||||
var count = 0;
|
var count = 0;
|
||||||
var transactions = await GetBankTransactionListAsync();
|
var transactions = GetBankTransactionList();
|
||||||
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);
|
||||||
|
|
@ -46,13 +48,16 @@ namespace Schaad.Accounting.Repositories
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await SaveAsync(transactions, BANK_TRANSACTIONS);
|
Save(transactions, BANK_TRANSACTIONS);
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BankTransaction> GetBankTransactionAsync(string id)
|
/// <summary>
|
||||||
|
/// Get bank transaction
|
||||||
|
/// </summary>
|
||||||
|
public BankTransaction GetBankTransaction(string id)
|
||||||
{
|
{
|
||||||
var transactions = await GetBankTransactionListAsync();
|
var transactions = GetBankTransactionList();
|
||||||
return transactions.FirstOrDefault(t => t.Id == id);
|
return transactions.FirstOrDefault(t => t.Id == id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
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;
|
||||||
|
|
@ -14,9 +12,9 @@ namespace Schaad.Accounting.Repositories
|
||||||
{
|
{
|
||||||
public abstract class BaseRepository
|
public abstract class BaseRepository
|
||||||
{
|
{
|
||||||
// One semaphore per absolute file path so concurrent SaveAsync+SaveAsync and
|
// One lock per absolute file path so concurrent Save+Save and Save+Load are serialized
|
||||||
// SaveAsync+LoadAsync are serialized and can't observe a half-written file.
|
// and can't observe a half-written file.
|
||||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> FileLocks =
|
private static readonly ConcurrentDictionary<string, object> FileLocks =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
protected readonly ISettingsService settingsService;
|
protected readonly ISettingsService settingsService;
|
||||||
|
|
@ -30,17 +28,10 @@ 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);
|
||||||
var semaphore = GetLock(filePath);
|
lock (GetLock(filePath))
|
||||||
semaphore.Wait();
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
if (File.Exists(filePath) == false)
|
if (File.Exists(filePath) == false)
|
||||||
{
|
{
|
||||||
|
|
@ -51,20 +42,16 @@ 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="SaveAsync"/> invalidates the entry.
|
/// the same scope return the cached list. <see cref="Save"/> invalidates the entry.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
protected Task<List<T>> LoadListAsync<T>(string fileName)
|
protected List<T> LoadList<T>(string fileName)
|
||||||
{
|
{
|
||||||
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
||||||
return cache.GetOrLoadAsync(filePath, async () => await LoadAsync<List<T>>(fileName) ?? new List<T>());
|
return cache.GetOrLoad(filePath, () => Load<List<T>>(fileName) ?? new List<T>());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -72,14 +59,12 @@ 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 async Task SaveAsync<T>(T obj, string fileName)
|
protected void Save<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";
|
||||||
|
|
||||||
var semaphore = GetLock(filePath);
|
lock (GetLock(filePath))
|
||||||
await semaphore.WaitAsync();
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
var settings = new XmlWriterSettings
|
var settings = new XmlWriterSettings
|
||||||
{
|
{
|
||||||
|
|
@ -89,16 +74,12 @@ namespace Schaad.Accounting.Repositories
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// XmlSerializer is synchronous — serialize to memory, then write async.
|
using (var writer = XmlWriter.Create(tmpPath, settings))
|
||||||
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)
|
||||||
|
|
@ -111,43 +92,34 @@ 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="LoadListAsync{T}"/> for repository reads.
|
/// existence bootstrapping). Prefer <see cref="LoadList{T}"/> for repository reads.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
protected async Task<T> LoadAsync<T>(string fileName)
|
protected T Load<T>(string fileName)
|
||||||
{
|
{
|
||||||
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
||||||
|
|
||||||
var semaphore = GetLock(filePath);
|
lock (GetLock(filePath))
|
||||||
await semaphore.WaitAsync();
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
if (File.Exists(filePath) == false)
|
if (File.Exists(filePath) == false)
|
||||||
{
|
{
|
||||||
return default(T);
|
return default(T);
|
||||||
}
|
}
|
||||||
|
|
||||||
var bytes = await File.ReadAllBytesAsync(filePath);
|
using (XmlReader reader = XmlReader.Create(filePath))
|
||||||
using var memory = new MemoryStream(bytes);
|
|
||||||
var serializer = new XmlSerializer(typeof(T));
|
|
||||||
return (T)serializer.Deserialize(memory);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
{
|
||||||
semaphore.Release();
|
var serializer = new XmlSerializer(typeof(T));
|
||||||
|
return (T)serializer.Deserialize(reader);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SemaphoreSlim GetLock(string filePath)
|
private static object GetLock(string filePath)
|
||||||
=> FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1));
|
=> FileLocks.GetOrAdd(filePath, _ => new object());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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,11 +17,17 @@ namespace Schaad.Accounting.Repositories
|
||||||
EnsureFileExists(BOOKING_RULES);
|
EnsureFileExists(BOOKING_RULES);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<BookingRule>> GetBookingRuleListAsync() => LoadListAsync<BookingRule>(BOOKING_RULES);
|
/// <summary>
|
||||||
|
/// Load booking rules
|
||||||
|
/// </summary>
|
||||||
|
public List<BookingRule> GetBookingRuleList() => LoadList<BookingRule>(BOOKING_RULES);
|
||||||
|
|
||||||
public async Task SaveBookingRuleAsync(BookingRule bookingRule)
|
/// <summary>
|
||||||
|
/// Save a booking rule (insert/update)
|
||||||
|
/// </summary>
|
||||||
|
public void SaveBookingRule(BookingRule bookingRule)
|
||||||
{
|
{
|
||||||
var bookingRules = await GetBookingRuleListAsync();
|
var bookingRules = GetBookingRuleList();
|
||||||
var idx = bookingRules.FindIndex(r => r.Id == bookingRule.Id);
|
var idx = bookingRules.FindIndex(r => r.Id == bookingRule.Id);
|
||||||
if (idx >= 0)
|
if (idx >= 0)
|
||||||
{
|
{
|
||||||
|
|
@ -33,20 +38,26 @@ namespace Schaad.Accounting.Repositories
|
||||||
bookingRule.Id = Guid.NewGuid().ToString();
|
bookingRule.Id = Guid.NewGuid().ToString();
|
||||||
bookingRules.Add(bookingRule);
|
bookingRules.Add(bookingRule);
|
||||||
}
|
}
|
||||||
await SaveAsync(bookingRules, BOOKING_RULES);
|
Save(bookingRules, BOOKING_RULES);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BookingRule> GetBookingRuleAsync(string id)
|
/// <summary>
|
||||||
|
/// Get booking rule
|
||||||
|
/// </summary>
|
||||||
|
public BookingRule GetBookingRule(string id)
|
||||||
{
|
{
|
||||||
var bookingRules = await GetBookingRuleListAsync();
|
var bookingRules = GetBookingRuleList();
|
||||||
return bookingRules.FirstOrDefault(t => t.Id == id);
|
return bookingRules.FirstOrDefault(t => t.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteBookingRuleAsync(string id)
|
/// <summary>
|
||||||
|
/// Delete booking rule
|
||||||
|
/// </summary>
|
||||||
|
public void DeleteBookingRule(string id)
|
||||||
{
|
{
|
||||||
var bookingRules = await GetBookingRuleListAsync();
|
var bookingRules = GetBookingRuleList();
|
||||||
bookingRules.RemoveAll(t => t.Id == id);
|
bookingRules.RemoveAll(t => t.Id == id);
|
||||||
await SaveAsync(bookingRules, BOOKING_RULES);
|
Save(bookingRules, BOOKING_RULES);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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,11 +17,17 @@ namespace Schaad.Accounting.Repositories
|
||||||
EnsureFileExists(BOOKING_TEXTS);
|
EnsureFileExists(BOOKING_TEXTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<BookingText>> GetBookingTextListAsync() => LoadListAsync<BookingText>(BOOKING_TEXTS);
|
/// <summary>
|
||||||
|
/// Load booking texts
|
||||||
|
/// </summary>
|
||||||
|
public List<BookingText> GetBookingTextList() => LoadList<BookingText>(BOOKING_TEXTS);
|
||||||
|
|
||||||
public async Task SaveBookingTextAsync(BookingText bookingText)
|
/// <summary>
|
||||||
|
/// Save a booking text (insert/update)
|
||||||
|
/// </summary>
|
||||||
|
public void SaveBookingText(BookingText bookingText)
|
||||||
{
|
{
|
||||||
var bookingTexts = await GetBookingTextListAsync();
|
var bookingTexts = GetBookingTextList();
|
||||||
var idx = bookingTexts.FindIndex(t => t.Id == bookingText.Id);
|
var idx = bookingTexts.FindIndex(t => t.Id == bookingText.Id);
|
||||||
if (idx >= 0)
|
if (idx >= 0)
|
||||||
{
|
{
|
||||||
|
|
@ -33,20 +38,26 @@ namespace Schaad.Accounting.Repositories
|
||||||
bookingText.Id = Guid.NewGuid().ToString();
|
bookingText.Id = Guid.NewGuid().ToString();
|
||||||
bookingTexts.Add(bookingText);
|
bookingTexts.Add(bookingText);
|
||||||
}
|
}
|
||||||
await SaveAsync(bookingTexts, BOOKING_TEXTS);
|
Save(bookingTexts, BOOKING_TEXTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BookingText> GetBookingTextAsync(string id)
|
/// <summary>
|
||||||
|
/// Get booking text
|
||||||
|
/// </summary>
|
||||||
|
public BookingText GetBookingText(string id)
|
||||||
{
|
{
|
||||||
var bookingTexts = await GetBookingTextListAsync();
|
var bookingTexts = GetBookingTextList();
|
||||||
return bookingTexts.FirstOrDefault(t => t.Id == id);
|
return bookingTexts.FirstOrDefault(t => t.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteBookingTextAsync(string id)
|
/// <summary>
|
||||||
|
/// Delete booking text
|
||||||
|
/// </summary>
|
||||||
|
public void DeleteBookingText(string id)
|
||||||
{
|
{
|
||||||
var bookingTexts = await GetBookingTextListAsync();
|
var bookingTexts = GetBookingTextList();
|
||||||
bookingTexts.RemoveAll(t => t.Id == id);
|
bookingTexts.RemoveAll(t => t.Id == id);
|
||||||
await SaveAsync(bookingTexts, BOOKING_TEXTS);
|
Save(bookingTexts, BOOKING_TEXTS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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,11 +17,17 @@ namespace Schaad.Accounting.Repositories
|
||||||
EnsureFileExists(SPLIT_PREDEFINITION);
|
EnsureFileExists(SPLIT_PREDEFINITION);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<SplitPredefinition>> GetSplitPredefinitionListAsync() => LoadListAsync<SplitPredefinition>(SPLIT_PREDEFINITION);
|
/// <summary>
|
||||||
|
/// Load split predefinitions
|
||||||
|
/// </summary>
|
||||||
|
public List<SplitPredefinition> GetSplitPredefinitionList() => LoadList<SplitPredefinition>(SPLIT_PREDEFINITION);
|
||||||
|
|
||||||
public async Task SaveSplitPredefinitionAsync(SplitPredefinition splitPredefinition)
|
/// <summary>
|
||||||
|
/// Save a split predefinition (insert/update)
|
||||||
|
/// </summary>
|
||||||
|
public void SaveSplitPredefinition(SplitPredefinition splitPredefinition)
|
||||||
{
|
{
|
||||||
var definitions = await GetSplitPredefinitionListAsync();
|
var definitions = GetSplitPredefinitionList();
|
||||||
var idx = definitions.FindIndex(d => d.Id == splitPredefinition.Id);
|
var idx = definitions.FindIndex(d => d.Id == splitPredefinition.Id);
|
||||||
if (idx >= 0)
|
if (idx >= 0)
|
||||||
{
|
{
|
||||||
|
|
@ -33,7 +38,7 @@ namespace Schaad.Accounting.Repositories
|
||||||
splitPredefinition.Id = Guid.NewGuid().ToString();
|
splitPredefinition.Id = Guid.NewGuid().ToString();
|
||||||
definitions.Add(splitPredefinition);
|
definitions.Add(splitPredefinition);
|
||||||
}
|
}
|
||||||
await SaveAsync(definitions, SPLIT_PREDEFINITION);
|
Save(definitions, SPLIT_PREDEFINITION);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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;
|
||||||
|
|
@ -24,11 +23,17 @@ namespace Schaad.Accounting.Repositories
|
||||||
classes.Add(ClassIds.Expenses, "Ausgaben");
|
classes.Add(ClassIds.Expenses, "Ausgaben");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<SubClass>> GetSubClassListAsync() => LoadListAsync<SubClass>(SUBCLASSES);
|
/// <summary>
|
||||||
|
/// Load subclasses
|
||||||
|
/// </summary>
|
||||||
|
public List<SubClass> GetSubClassList() => LoadList<SubClass>(SUBCLASSES);
|
||||||
|
|
||||||
public async Task SaveSubClassAsync(SubClass subClass)
|
/// <summary>
|
||||||
|
/// Save a subclass (insert/update)
|
||||||
|
/// </summary>
|
||||||
|
public void SaveSubClass(SubClass subClass)
|
||||||
{
|
{
|
||||||
var subclasses = await GetSubClassListAsync();
|
var subclasses = GetSubClassList();
|
||||||
var idx = subclasses.FindIndex(s => s.Id == subClass.Id);
|
var idx = subclasses.FindIndex(s => s.Id == subClass.Id);
|
||||||
if (idx >= 0)
|
if (idx >= 0)
|
||||||
{
|
{
|
||||||
|
|
@ -39,20 +44,23 @@ namespace Schaad.Accounting.Repositories
|
||||||
subClass.Id = Guid.NewGuid().ToString();
|
subClass.Id = Guid.NewGuid().ToString();
|
||||||
subclasses.Add(subClass);
|
subclasses.Add(subClass);
|
||||||
}
|
}
|
||||||
await SaveAsync(subclasses, SUBCLASSES);
|
Save(subclasses, SUBCLASSES);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SubClass> GetSubClassAsync(string id)
|
/// <summary>
|
||||||
|
/// Get subclass
|
||||||
|
/// </summary>
|
||||||
|
public SubClass GetSubClass(string id)
|
||||||
{
|
{
|
||||||
var subclasses = await GetSubClassListAsync();
|
var subclasses = GetSubClassList();
|
||||||
return subclasses.FirstOrDefault(t => t.Id == id);
|
return subclasses.FirstOrDefault(t => t.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteSubClassAsync(string id)
|
public void DeleteSubClass(string id)
|
||||||
{
|
{
|
||||||
var subclasses = await GetSubClassListAsync();
|
var subclasses = GetSubClassList();
|
||||||
subclasses.RemoveAll(t => t.Id == id);
|
subclasses.RemoveAll(t => t.Id == id);
|
||||||
await SaveAsync(subclasses, SUBCLASSES);
|
Save(subclasses, SUBCLASSES);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Dictionary<int, string> GetClasses()
|
public Dictionary<int, string> GetClasses()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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;
|
||||||
|
|
@ -19,14 +18,20 @@ namespace Schaad.Accounting.Repositories
|
||||||
this.accountRepository = accountRepository;
|
this.accountRepository = accountRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<Transaction>> GetTransactionListAsync() => LoadListAsync<Transaction>(TRANSACTIONS);
|
/// <summary>
|
||||||
|
/// Get transaction list
|
||||||
|
/// </summary>
|
||||||
|
public List<Transaction> GetTransactionList() => LoadList<Transaction>(TRANSACTIONS);
|
||||||
|
|
||||||
public async Task SaveTransactionAsync(Transaction transaction)
|
/// <summary>
|
||||||
|
/// 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 = (await accountRepository.GetAccountAsync(transaction.OriginAccountId)).IsFxAccount;
|
var originIsFx = accountRepository.GetAccount(transaction.OriginAccountId).IsFxAccount;
|
||||||
var targetIsFx = (await accountRepository.GetAccountAsync(transaction.TargetAccountId)).IsFxAccount;
|
var targetIsFx = accountRepository.GetAccount(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
|
||||||
|
|
@ -45,7 +50,7 @@ namespace Schaad.Accounting.Repositories
|
||||||
transaction.BookingDate = transaction.ValueDate;
|
transaction.BookingDate = transaction.ValueDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
var transactionList = await GetTransactionListAsync();
|
var transactionList = GetTransactionList();
|
||||||
var idx = transactionList.FindIndex(t => t.Id == transaction.Id);
|
var idx = transactionList.FindIndex(t => t.Id == transaction.Id);
|
||||||
if (idx >= 0)
|
if (idx >= 0)
|
||||||
{
|
{
|
||||||
|
|
@ -56,12 +61,15 @@ namespace Schaad.Accounting.Repositories
|
||||||
transaction.Id = Guid.NewGuid().ToString();
|
transaction.Id = Guid.NewGuid().ToString();
|
||||||
transactionList.Add(transaction);
|
transactionList.Add(transaction);
|
||||||
}
|
}
|
||||||
await SaveAsync(transactionList, TRANSACTIONS);
|
Save(transactionList, TRANSACTIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Transaction> GetTransactionAsync(string id)
|
/// <summary>
|
||||||
|
/// Get transaction
|
||||||
|
/// </summary>
|
||||||
|
public Transaction GetTransaction(string id)
|
||||||
{
|
{
|
||||||
var transactions = await GetTransactionListAsync();
|
var transactions = GetTransactionList();
|
||||||
var stored = transactions.FirstOrDefault(t => t.Id == id);
|
var stored = transactions.FirstOrDefault(t => t.Id == id);
|
||||||
if (stored == null)
|
if (stored == null)
|
||||||
{
|
{
|
||||||
|
|
@ -71,8 +79,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 = (await accountRepository.GetAccountAsync(result.OriginAccountId)).IsFxAccount
|
var isFxAccount = accountRepository.GetAccount(result.OriginAccountId).IsFxAccount
|
||||||
|| (await accountRepository.GetAccountAsync(result.TargetAccountId)).IsFxAccount;
|
|| accountRepository.GetAccount(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;
|
||||||
|
|
@ -81,11 +89,14 @@ namespace Schaad.Accounting.Repositories
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteTransactionAsync(string id)
|
/// <summary>
|
||||||
|
/// Delete transaction
|
||||||
|
/// </summary>
|
||||||
|
public void DeleteTransaction(string id)
|
||||||
{
|
{
|
||||||
var transactions = await GetTransactionListAsync();
|
var transactions = GetTransactionList();
|
||||||
transactions.RemoveAll(t => t.Id == id);
|
transactions.RemoveAll(t => t.Id == id);
|
||||||
await SaveAsync(transactions, TRANSACTIONS);
|
Save(transactions, TRANSACTIONS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace Schaad.Accounting.Repositories
|
namespace Schaad.Accounting.Repositories
|
||||||
{
|
{
|
||||||
|
|
@ -8,7 +7,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.SaveAsync"/>,
|
/// then loaded at most once per circuit. Writes go through <see cref="BaseRepository.Save"/>,
|
||||||
/// 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
|
||||||
|
|
@ -19,14 +18,14 @@ namespace Schaad.Accounting.Repositories
|
||||||
private readonly Dictionary<string, object> entries =
|
private readonly Dictionary<string, object> entries =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public async Task<List<T>> GetOrLoadAsync<T>(string cacheKey, Func<Task<List<T>>> loader)
|
public List<T> GetOrLoad<T>(string cacheKey, Func<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 = await loader();
|
var list = loader();
|
||||||
entries[cacheKey] = list;
|
entries[cacheKey] = list;
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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;
|
||||||
|
|
@ -19,9 +18,9 @@ namespace Schaad.Accounting.Services
|
||||||
this.viewService = viewService;
|
this.viewService = viewService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<DataSerie>> GetExpensesPerMonthAsync()
|
public IReadOnlyList<DataSerie> GetExpensesPerMonth()
|
||||||
{
|
{
|
||||||
var expenseTransactions = (await viewService.GetTransactionViewListAsync())
|
var expenseTransactions = viewService.GetTransactionViewList()
|
||||||
.Where(t => t.TargetAccount.Class == ClassIds.Expenses)
|
.Where(t => t.TargetAccount.Class == ClassIds.Expenses)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|
@ -32,7 +31,7 @@ namespace Schaad.Accounting.Services
|
||||||
|
|
||||||
return distinctSubClasses > 1
|
return distinctSubClasses > 1
|
||||||
? GetSubClassExpensesPerMonth(expenseTransactions)
|
? GetSubClassExpensesPerMonth(expenseTransactions)
|
||||||
: await GetAccountExpensesPerMonthAsync();
|
: GetAccountExpensesPerMonth();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<DataSerie> GetSubClassExpensesPerMonth(List<TransactionDataset> expenseTransactions)
|
private List<DataSerie> GetSubClassExpensesPerMonth(List<TransactionDataset> expenseTransactions)
|
||||||
|
|
@ -60,11 +59,11 @@ namespace Schaad.Accounting.Services
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<DataSerie>> GetAccountExpensesPerMonthAsync()
|
private List<DataSerie> GetAccountExpensesPerMonth()
|
||||||
{
|
{
|
||||||
var year = settingsService.GetYear();
|
var year = settingsService.GetYear();
|
||||||
var allTransactions = await viewService.GetTransactionViewListAsync();
|
var allTransactions = viewService.GetTransactionViewList();
|
||||||
var expenseAccounts = (await viewService.GetAccountViewListAsync()).Where(a => a.Class == ClassIds.Expenses);
|
var expenseAccounts = viewService.GetAccountViewList().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)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ 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;
|
||||||
|
|
@ -48,14 +47,15 @@ namespace Schaad.Accounting.Services
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload account statement file (mt940, camt053)
|
// Upload account statement file (mt940, camt053)
|
||||||
public async Task<IReadOnlyList<MessageDataset>> ImportAccountStatementFileAsync(string filePath)
|
// http://www.mikesdotnetting.com/article/288/asp-net-5-uploading-files-with-asp-net-mvc-6
|
||||||
|
public IReadOnlyList<MessageDataset> ImportAccountStatementFile(string filePath)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Importing account statement file {FilePath}", filePath);
|
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 = (await accountRepository.GetAccountListAsync()).Select(a => a.BankAccountNumber).ToList();
|
var accountList = accountRepository.GetAccountList().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 = await bankTransactionRepository.SaveBankTransactionListAsync(account.AccountNumber, transactionsThisYear);
|
var count = bankTransactionRepository.SaveBankTransactionList(account.AccountNumber, transactionsThisYear);
|
||||||
await accountRepository.SaveBankAccountBalanceAsync(account.AccountNumber, (decimal)account.EndBalance.Value);
|
accountRepository.SaveBankAccountBalance(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 async Task<byte[]> GetTransactionListCsvAsync(string accountId)
|
public byte[] GetTransactionListCsv(string accountId)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
var account = await accountRepository.GetAccountAsync(accountId);
|
var account = accountRepository.GetAccount(accountId);
|
||||||
var transactions = (await transactionsRepository.GetTransactionListAsync())
|
var transactions = transactionsRepository.GetTransactionList()
|
||||||
.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)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
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
|
||||||
{
|
{
|
||||||
Task<IReadOnlyList<DataSerie>> GetExpensesPerMonthAsync();
|
IReadOnlyList<DataSerie> GetExpensesPerMonth();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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;
|
||||||
|
|
@ -9,8 +8,8 @@ namespace Schaad.Accounting.Interfaces
|
||||||
public interface IFileService
|
public interface IFileService
|
||||||
{
|
{
|
||||||
string Backup();
|
string Backup();
|
||||||
Task<IReadOnlyList<MessageDataset>> ImportAccountStatementFileAsync(string filePath);
|
IReadOnlyList<MessageDataset> ImportAccountStatementFile(string filePath);
|
||||||
IReadOnlyList<CreditCardTransaction> ImportCreditCardStatementFile(CreditCardProvider creditCardProvider, string filePath);
|
IReadOnlyList<CreditCardTransaction> ImportCreditCardStatementFile(CreditCardProvider creditCardProvider, string filePath);
|
||||||
Task<byte[]> GetTransactionListCsvAsync(string accountId);
|
byte[] GetTransactionListCsv(string accountId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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;
|
||||||
|
|
@ -9,24 +8,24 @@ namespace Schaad.Accounting.Interfaces
|
||||||
{
|
{
|
||||||
public interface IViewService
|
public interface IViewService
|
||||||
{
|
{
|
||||||
Task<AccountDataset> GetAccountViewAsync(string id);
|
AccountDataset GetAccountView(string id);
|
||||||
|
|
||||||
Task<List<AccountDataset>> GetAccountViewListAsync();
|
List<AccountDataset> GetAccountViewList();
|
||||||
|
|
||||||
Task<BalanceDataset> GetBalanceViewAsync();
|
BalanceDataset GetBalanceView();
|
||||||
|
|
||||||
Task<BalanceSheetDataset> GetBalanceSheetViewAsync(int year);
|
BalanceSheetDataset GetBalanceSheetView(int year);
|
||||||
|
|
||||||
Task<List<TransactionDataset>> GetTransactionViewListAsync();
|
List<TransactionDataset> GetTransactionViewList();
|
||||||
|
|
||||||
Task<List<TransactionDataset>> GetTransactionViewListAsync(string accountId);
|
List<TransactionDataset> GetTransactionViewList(string accountId);
|
||||||
|
|
||||||
Task<List<BookingRuleDataset>> GetBookingRuleViewListAsync();
|
List<BookingRuleDataset> GetBookingRuleViewList();
|
||||||
|
|
||||||
Task<List<BankTransaction>> GetOpenBankTransactionListAsync();
|
List<BankTransaction> GetOpenBankTransactionList();
|
||||||
|
|
||||||
Task<List<Transaction>> MatchOpenBankTransactionsAsync();
|
List<Transaction> MatchOpenBankTransactions();
|
||||||
|
|
||||||
Task<List<Transaction>> MatchCreditCardTransactionsAsync(string bankTransactionId, IReadOnlyList<CreditCardTransaction> creditCardTransactions);
|
List<Transaction> MatchCreditCardTransactions(string bankTransactionId, IReadOnlyList<CreditCardTransaction> creditCardTransactions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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;
|
||||||
|
|
@ -39,17 +38,23 @@ namespace Schaad.Accounting.Services
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AccountDataset> GetAccountViewAsync(string id)
|
/// <summary>
|
||||||
|
/// Load accounts
|
||||||
|
/// </summary>
|
||||||
|
public AccountDataset GetAccountView(string id)
|
||||||
{
|
{
|
||||||
var accounts = await GetAccountViewListAsync();
|
var accounts = GetAccountViewList();
|
||||||
return accounts.Single(t => t.Id == id);
|
return accounts.Single(t => t.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<AccountDataset>> GetAccountViewListAsync()
|
/// <summary>
|
||||||
|
/// Load accounts and calculates their balances
|
||||||
|
/// </summary>
|
||||||
|
public List<AccountDataset> GetAccountViewList()
|
||||||
{
|
{
|
||||||
var accounts = await accountRepository.GetAccountListAsync();
|
var accounts = accountRepository.GetAccountList();
|
||||||
var transactionList = await GetTransactionViewListAsync();
|
var transactionList = GetTransactionViewList();
|
||||||
var subClassNameByNumber = (await subclassRepository.GetSubClassListAsync()).ToDictionary(s => s.Number, s => s.Name);
|
var subClassNameByNumber = subclassRepository.GetSubClassList().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());
|
||||||
|
|
@ -88,40 +93,46 @@ namespace Schaad.Accounting.Services
|
||||||
return balance;
|
return balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BalanceDataset> GetBalanceViewAsync()
|
public BalanceDataset GetBalanceView()
|
||||||
{
|
{
|
||||||
var accountList = await GetAccountViewListAsync();
|
var accountList = GetAccountViewList();
|
||||||
return new BalanceDataset(
|
var balanceView = 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(),
|
||||||
TotalActivaCHF: Math.Abs(accountList.Where(m => m.Class == ClassIds.Activa).Sum(m => m.BalanceCHF)),
|
totalActivaCHF: Math.Abs(accountList.Where(m => m.Class == ClassIds.Activa).Sum(m => m.BalanceCHF)),
|
||||||
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))
|
||||||
|
);
|
||||||
|
|
||||||
|
return balanceView;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BalanceSheetDataset> GetBalanceSheetViewAsync(int year)
|
public BalanceSheetDataset GetBalanceSheetView(int year)
|
||||||
{
|
{
|
||||||
var accountList = await GetAccountViewListAsync();
|
var accountList = GetAccountViewList();
|
||||||
|
|
||||||
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));
|
||||||
|
|
||||||
return new BalanceSheetDataset(
|
var balanceView = 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 async Task<List<TransactionDataset>> GetTransactionViewListAsync()
|
public List<TransactionDataset> GetTransactionViewList()
|
||||||
{
|
{
|
||||||
var accountsById = (await accountRepository.GetAccountListAsync()).ToDictionary(a => a.Id);
|
var accountsById = accountRepository.GetAccountList().ToDictionary(a => a.Id);
|
||||||
var transactionList = await transactionRepository.GetTransactionListAsync();
|
var transactionList = transactionRepository.GetTransactionList();
|
||||||
|
|
||||||
return transactionList.Select(t =>
|
return transactionList.Select(t =>
|
||||||
new TransactionDataset(
|
new TransactionDataset(
|
||||||
|
|
@ -132,13 +143,12 @@ namespace Schaad.Accounting.Services
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get transaction list filtered to a single account, with values sign-flipped
|
/// Get transaction list with the origin and target account for each transaction
|
||||||
/// when the requested account is the origin of an activa booking.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<List<TransactionDataset>> GetTransactionViewListAsync(string accountId)
|
public List<TransactionDataset> GetTransactionViewList(string accountId)
|
||||||
{
|
{
|
||||||
var accountsById = (await accountRepository.GetAccountListAsync()).ToDictionary(a => a.Id);
|
var accountsById = accountRepository.GetAccountList().ToDictionary(a => a.Id);
|
||||||
var transactionList = (await transactionRepository.GetTransactionListAsync())
|
var transactionList = transactionRepository.GetTransactionList()
|
||||||
.Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId);
|
.Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId);
|
||||||
|
|
||||||
return transactionList.Select(t =>
|
return transactionList.Select(t =>
|
||||||
|
|
@ -164,10 +174,10 @@ namespace Schaad.Accounting.Services
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get booking rules with their account
|
/// Get booking rules with their account
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<List<BookingRuleDataset>> GetBookingRuleViewListAsync()
|
public List<BookingRuleDataset> GetBookingRuleViewList()
|
||||||
{
|
{
|
||||||
var accountsById = (await accountRepository.GetAccountListAsync()).ToDictionary(a => a.Id);
|
var accountsById = accountRepository.GetAccountList().ToDictionary(a => a.Id);
|
||||||
var bookingRules = await bookingRuleRepository.GetBookingRuleListAsync();
|
var bookingRules = bookingRuleRepository.GetBookingRuleList();
|
||||||
|
|
||||||
return bookingRules.Select(t =>
|
return bookingRules.Select(t =>
|
||||||
new BookingRuleDataset(t, accountsById[t.AccountId].Name))
|
new BookingRuleDataset(t, accountsById[t.AccountId].Name))
|
||||||
|
|
@ -177,26 +187,28 @@ namespace Schaad.Accounting.Services
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Load open bank transactions
|
/// Load open bank transactions
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<List<BankTransaction>> GetOpenBankTransactionListAsync()
|
public List<BankTransaction> GetOpenBankTransactionList()
|
||||||
{
|
{
|
||||||
var transactions = (await transactionRepository.GetTransactionListAsync())
|
var transactions = transactionRepository
|
||||||
|
.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 = (await bankTransactionRepository.GetBankTransactionListAsync())
|
var bankTransactions = bankTransactionRepository
|
||||||
|
.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 async Task<List<Transaction>> MatchOpenBankTransactionsAsync()
|
public List<Transaction> MatchOpenBankTransactions()
|
||||||
{
|
{
|
||||||
var newTransactionList = new List<Transaction>();
|
var newTransactionList = new List<Transaction>();
|
||||||
var transactions = await transactionRepository.GetTransactionListAsync();
|
var transactions = transactionRepository.GetTransactionList();
|
||||||
var accounts = await accountRepository.GetAccountListAsync();
|
var accounts = accountRepository.GetAccountList();
|
||||||
var bookingRules = await bookingRuleRepository.GetBookingRuleListAsync();
|
var bookingRules = bookingRuleRepository.GetBookingRuleList();
|
||||||
var bankTransactions = (await GetOpenBankTransactionListAsync()).OrderBy(t => t.ValueDate).ToList();
|
var bankTransactions = GetOpenBankTransactionList().OrderBy(t => t.ValueDate).ToList();
|
||||||
foreach (var bankTransaction in bankTransactions)
|
foreach (var bankTransaction in bankTransactions)
|
||||||
{
|
{
|
||||||
var trx = new Transaction(bankTransaction, accounts);
|
var trx = new Transaction(bankTransaction, accounts);
|
||||||
|
|
@ -208,15 +220,14 @@ 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 async Task<List<Transaction>> MatchCreditCardTransactionsAsync(string bankTransactionId, IReadOnlyList<CreditCardTransaction> creditCardTransactions)
|
public List<Transaction> MatchCreditCardTransactions(string bankTransactionId, IReadOnlyList<CreditCardTransaction> creditCardTransactions)
|
||||||
{
|
{
|
||||||
var accounts = await accountRepository.GetAccountListAsync();
|
var accounts = accountRepository.GetAccountList();
|
||||||
var bankTrx = await bankTransactionRepository.GetBankTransactionAsync(bankTransactionId);
|
var bankTrx = bankTransactionRepository.GetBankTransaction(bankTransactionId);
|
||||||
var bookingRules = await bookingRuleRepository.GetBookingRuleListAsync();
|
var bookingRules = bookingRuleRepository.GetBookingRuleList();
|
||||||
|
|
||||||
var trxList = new List<Transaction>();
|
var trxList = new List<Transaction>();
|
||||||
foreach (var creditCardTransaction in creditCardTransactions)
|
foreach (var creditCardTransaction in creditCardTransactions)
|
||||||
|
|
|
||||||
|
|
@ -34,79 +34,79 @@ public class AccountRepositoryTestShould : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AssignNewIdWhenSavingAccountWithoutId()
|
public void AssignNewIdWhenSavingAccountWithoutId()
|
||||||
{
|
{
|
||||||
var account = new Account { Number = 1000, Name = "Cash", Currency = "CHF" };
|
var account = new Account { Number = 1000, Name = "Cash", Currency = "CHF" };
|
||||||
|
|
||||||
await sut.SaveAccountAsync(account);
|
sut.SaveAccount(account);
|
||||||
|
|
||||||
var stored = (await sut.GetAccountListAsync()).Single();
|
var stored = sut.GetAccountList().Single();
|
||||||
stored.Id.ShouldNotBeNullOrEmpty();
|
stored.Id.ShouldNotBeNullOrEmpty();
|
||||||
stored.Name.ShouldBe("Cash");
|
stored.Name.ShouldBe("Cash");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateInPlaceWhenSavingAccountWithExistingId()
|
public void UpdateInPlaceWhenSavingAccountWithExistingId()
|
||||||
{
|
{
|
||||||
await sut.SaveAccountAsync(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
|
sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
|
||||||
var id = (await sut.GetAccountListAsync()).Single().Id;
|
var id = sut.GetAccountList().Single().Id;
|
||||||
|
|
||||||
await sut.SaveAccountAsync(new Account { Id = id, Number = 1000, Name = "Cash renamed", Currency = "CHF" });
|
sut.SaveAccount(new Account { Id = id, Number = 1000, Name = "Cash renamed", Currency = "CHF" });
|
||||||
|
|
||||||
var accounts = await sut.GetAccountListAsync();
|
var accounts = sut.GetAccountList();
|
||||||
accounts.Count.ShouldBe(1);
|
accounts.Count.ShouldBe(1);
|
||||||
accounts[0].Name.ShouldBe("Cash renamed");
|
accounts[0].Name.ShouldBe("Cash renamed");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DefaultCurrencyToChfWhenSavingAccountWithoutCurrency()
|
public void DefaultCurrencyToChfWhenSavingAccountWithoutCurrency()
|
||||||
{
|
{
|
||||||
await sut.SaveAccountAsync(new Account { Number = 1000, Name = "Cash", Currency = null! });
|
sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = null! });
|
||||||
|
|
||||||
(await sut.GetAccountListAsync()).Single().Currency.ShouldBe("CHF");
|
sut.GetAccountList().Single().Currency.ShouldBe("CHF");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RemoveAccountWhenDeletingById()
|
public void RemoveAccountWhenDeletingById()
|
||||||
{
|
{
|
||||||
await sut.SaveAccountAsync(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
|
sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
|
||||||
var id = (await sut.GetAccountListAsync()).Single().Id;
|
var id = sut.GetAccountList().Single().Id;
|
||||||
|
|
||||||
await sut.DeleteAccountAsync(id);
|
sut.DeleteAccount(id);
|
||||||
|
|
||||||
(await sut.GetAccountListAsync()).ShouldBeEmpty();
|
sut.GetAccountList().ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReturnAccountWhenLookingUpByBankAccountNumber()
|
public void ReturnAccountWhenLookingUpByBankAccountNumber()
|
||||||
{
|
{
|
||||||
await sut.SaveAccountAsync(new Account { Number = 1010, Name = "Checking", Currency = "CHF", BankAccountNumber = "CH01" });
|
sut.SaveAccount(new Account { Number = 1010, Name = "Checking", Currency = "CHF", BankAccountNumber = "CH01" });
|
||||||
await sut.SaveAccountAsync(new Account { Number = 1020, Name = "Savings", Currency = "CHF", BankAccountNumber = "CH02" });
|
sut.SaveAccount(new Account { Number = 1020, Name = "Savings", Currency = "CHF", BankAccountNumber = "CH02" });
|
||||||
|
|
||||||
(await sut.GetAccountForBankAccountNumberAsync("CH01")).Name.ShouldBe("Checking");
|
sut.GetAccountForBankAccountNumber("CH01").Name.ShouldBe("Checking");
|
||||||
(await sut.GetAccountForBankAccountNumberAsync("CH02")).Name.ShouldBe("Savings");
|
sut.GetAccountForBankAccountNumber("CH02").Name.ShouldBe("Savings");
|
||||||
(await sut.GetAccountForBankAccountNumberAsync("CH99")).ShouldBeNull();
|
sut.GetAccountForBankAccountNumber("CH99").ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateLastBankBalanceWhenSavingBankBalance()
|
public void UpdateLastBankBalanceWhenSavingBankBalance()
|
||||||
{
|
{
|
||||||
await sut.SaveAccountAsync(new Account { Number = 1010, Name = "Checking", Currency = "CHF", BankAccountNumber = "CH01" });
|
sut.SaveAccount(new Account { Number = 1010, Name = "Checking", Currency = "CHF", BankAccountNumber = "CH01" });
|
||||||
|
|
||||||
await sut.SaveBankAccountBalanceAsync("CH01", 1234.56m);
|
sut.SaveBankAccountBalance("CH01", 1234.56m);
|
||||||
|
|
||||||
(await sut.GetAccountForBankAccountNumberAsync("CH01")).LastBankBalance.ShouldBe(1234.56m);
|
sut.GetAccountForBankAccountNumber("CH01").LastBankBalance.ShouldBe(1234.56m);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SurviveConstructorReRunWhenAccountsFileAlreadyExists()
|
public void SurviveConstructorReRunWhenAccountsFileAlreadyExists()
|
||||||
{
|
{
|
||||||
await sut.SaveAccountAsync(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
|
sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
|
||||||
|
|
||||||
// Re-construct with the same directory: existing file, no year rollover, no data loss.
|
// Re-construct with the same directory: existing file, no year rollover, no data loss.
|
||||||
var fresh = new AccountRepository(settingsService, new RepositoryCache(), NullLogger<AccountRepository>.Instance);
|
var fresh = new AccountRepository(settingsService, new RepositoryCache(), NullLogger<AccountRepository>.Instance);
|
||||||
|
|
||||||
var accounts = await fresh.GetAccountListAsync();
|
var accounts = fresh.GetAccountList();
|
||||||
accounts.Count.ShouldBe(1);
|
accounts.Count.ShouldBe(1);
|
||||||
accounts[0].Name.ShouldBe("Cash");
|
accounts[0].Name.ShouldBe("Cash");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,18 +20,18 @@ public class ChartServiceTestShould
|
||||||
private ChartService BuildService() => new(settingsService, viewService);
|
private ChartService BuildService() => new(settingsService, viewService);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task HonourSelectedYearWhenBuildingMonthlySeries()
|
public void 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.GetAccountViewListAsync().Returns(new List<AccountDataset> { AsDataset(groceries), AsDataset(checking) });
|
viewService.GetAccountViewList().Returns(new List<AccountDataset> { AsDataset(groceries), AsDataset(checking) });
|
||||||
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
|
viewService.GetTransactionViewList().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 = await BuildService().GetExpensesPerMonthAsync();
|
var series = BuildService().GetExpensesPerMonth();
|
||||||
|
|
||||||
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 async Task GroupExpensesByAccountWhenAllExpensesShareOneSubClass()
|
public void GroupExpensesByAccountWhenAllExpensesShareOneSubClass()
|
||||||
{
|
{
|
||||||
var mannenbach = MakeAccount("m1", 4000);
|
var mannenbach = MakeAccount("m1", 4000); // SubClass 40
|
||||||
var mannenbach2 = MakeAccount("m2", 4001);
|
var mannenbach2 = MakeAccount("m2", 4001); // SubClass 40 (same)
|
||||||
var checking = MakeAccount("chk", 1010);
|
var checking = MakeAccount("chk", 1010);
|
||||||
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
|
viewService.GetAccountViewList().Returns(new List<AccountDataset>
|
||||||
{
|
{
|
||||||
AsDataset(mannenbach), AsDataset(mannenbach2), AsDataset(checking)
|
AsDataset(mannenbach), AsDataset(mannenbach2), AsDataset(checking)
|
||||||
});
|
});
|
||||||
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
|
viewService.GetTransactionViewList().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 = await BuildService().GetExpensesPerMonthAsync();
|
var series = BuildService().GetExpensesPerMonth();
|
||||||
|
|
||||||
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 async Task GroupExpensesBySubClassWhenMultipleSubClassesAreUsed()
|
public void GroupExpensesBySubClassWhenMultipleSubClassesAreUsed()
|
||||||
{
|
{
|
||||||
var groceries = MakeAccount("groc", 4200);
|
var groceries = MakeAccount("groc", 4200); // SubClass 42
|
||||||
var utilities = MakeAccount("util", 4300);
|
var utilities = MakeAccount("util", 4300); // SubClass 43
|
||||||
var checking = MakeAccount("chk", 1010);
|
var checking = MakeAccount("chk", 1010);
|
||||||
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
|
viewService.GetAccountViewList().Returns(new List<AccountDataset>
|
||||||
{
|
{
|
||||||
AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
|
AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
|
||||||
});
|
});
|
||||||
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
|
viewService.GetTransactionViewList().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 = await BuildService().GetExpensesPerMonthAsync();
|
var series = BuildService().GetExpensesPerMonth();
|
||||||
|
|
||||||
series.Count.ShouldBe(2);
|
series.Count.ShouldBe(2);
|
||||||
series.ShouldContain(s => s.Id == "42");
|
series.ShouldContain(s => s.Id == "42"); // sub-class number as id
|
||||||
series.ShouldContain(s => s.Id == "43");
|
series.ShouldContain(s => s.Id == "43");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SkipAccountsWithoutTransactionsWhenBuildingSeries()
|
public void SkipAccountsWithoutTransactionsWhenBuildingSeries()
|
||||||
{
|
{
|
||||||
var groceries = MakeAccount("groc", 4000);
|
var groceries = MakeAccount("groc", 4000); // has activity
|
||||||
var utilities = MakeAccount("util", 4001);
|
var utilities = MakeAccount("util", 4001); // same subclass, no activity
|
||||||
var checking = MakeAccount("chk", 1010);
|
var checking = MakeAccount("chk", 1010);
|
||||||
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
|
viewService.GetAccountViewList().Returns(new List<AccountDataset>
|
||||||
{
|
{
|
||||||
AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
|
AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
|
||||||
});
|
});
|
||||||
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
|
viewService.GetTransactionViewList().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 = await BuildService().GetExpensesPerMonthAsync();
|
var series = BuildService().GetExpensesPerMonth();
|
||||||
|
|
||||||
series.Count.ShouldBe(1);
|
series.Count.ShouldBe(1);
|
||||||
series[0].Id.ShouldBe("groc");
|
series[0].Id.ShouldBe("groc");
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,11 +30,13 @@ public class FileServiceTestShould
|
||||||
accountStatementService, creditCardStatementService, NullLogger<FileService>.Instance);
|
accountStatementService, creditCardStatementService, NullLogger<FileService>.Instance);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EmitHeaderRowAndRunningBalanceWhenExportingTransactionsCsv()
|
public void 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 };
|
||||||
accountRepo.GetAccountAsync("chk").Returns(checking);
|
var groceries = new Account { Id = "groc", Number = 4200, Name = "Groceries", Currency = "CHF" };
|
||||||
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
|
var salary = new Account { Id = "sal", Number = 3000, Name = "Salary", Currency = "CHF" };
|
||||||
|
accountRepo.GetAccount("chk").Returns(checking);
|
||||||
|
transactionRepo.GetTransactionList().Returns(new List<Transaction>
|
||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
|
|
@ -46,23 +50,26 @@ public class FileServiceTestShould
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
var bytes = await BuildService().GetTransactionListCsvAsync("chk");
|
var bytes = BuildService().GetTransactionListCsv("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 async Task OrderTransactionsByBookingDateThenValueDateThenValueWhenExporting()
|
public void 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.GetAccountAsync("chk").Returns(checking);
|
accountRepo.GetAccount("chk").Returns(checking);
|
||||||
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
|
transactionRepo.GetTransactionList().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",
|
||||||
|
|
@ -71,28 +78,30 @@ 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(await BuildService().GetTransactionListCsvAsync("chk"));
|
var csv = Encoding.GetEncoding("ISO-8859-1").GetString(BuildService().GetTransactionListCsv("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 async Task NotMutateTransactionValueWhenExportingCsv()
|
public void 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.GetAccountAsync("chk").Returns(checking);
|
accountRepo.GetAccount("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.GetTransactionListAsync().Returns(new List<Transaction> { outgoing });
|
transactionRepo.GetTransactionList().Returns(new List<Transaction> { outgoing });
|
||||||
|
|
||||||
await BuildService().GetTransactionListCsvAsync("chk");
|
BuildService().GetTransactionListCsv("chk");
|
||||||
|
|
||||||
|
// Regression: earlier code did `trx.Value *= -1` mid-loop.
|
||||||
outgoing.Value.ShouldBe(42m);
|
outgoing.Value.ShouldBe(42m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,13 @@ namespace Schaad.Accounting.Tests;
|
||||||
public class RepositoryCacheTestShould
|
public class RepositoryCacheTestShould
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CallLoaderOnceWhenKeyIsRequestedRepeatedly()
|
public void CallLoaderOnceWhenKeyIsRequestedRepeatedly()
|
||||||
{
|
{
|
||||||
var cache = new RepositoryCache();
|
var cache = new RepositoryCache();
|
||||||
var loaderCalls = 0;
|
var loaderCalls = 0;
|
||||||
|
|
||||||
var first = await cache.GetOrLoadAsync("key", () => { loaderCalls++; return Task.FromResult(new List<int> { 1, 2 }); });
|
var first = cache.GetOrLoad("key", () => { loaderCalls++; return new List<int> { 1, 2 }; });
|
||||||
var second = await cache.GetOrLoadAsync("key", () => { loaderCalls++; return Task.FromResult(new List<int> { 99 }); });
|
var second = cache.GetOrLoad("key", () => { loaderCalls++; return 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 async Task KeepEntriesSeparateWhenKeysDiffer()
|
public void KeepEntriesSeparateWhenKeysDiffer()
|
||||||
{
|
{
|
||||||
var cache = new RepositoryCache();
|
var cache = new RepositoryCache();
|
||||||
|
|
||||||
var a = await cache.GetOrLoadAsync("a", () => Task.FromResult(new List<int> { 1 }));
|
var a = cache.GetOrLoad("a", () => new List<int> { 1 });
|
||||||
var b = await cache.GetOrLoadAsync("b", () => Task.FromResult(new List<int> { 2 }));
|
var b = cache.GetOrLoad("b", () => 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 async Task ReloadFromLoaderWhenKeyIsInvalidated()
|
public void ReloadFromLoaderWhenKeyIsInvalidated()
|
||||||
{
|
{
|
||||||
var cache = new RepositoryCache();
|
var cache = new RepositoryCache();
|
||||||
var loaderCalls = 0;
|
var loaderCalls = 0;
|
||||||
|
|
||||||
await cache.GetOrLoadAsync("k", () => { loaderCalls++; return Task.FromResult(new List<int> { 1 }); });
|
cache.GetOrLoad("k", () => { loaderCalls++; return new List<int> { 1 }; });
|
||||||
cache.Invalidate("k");
|
cache.Invalidate("k");
|
||||||
await cache.GetOrLoadAsync("k", () => { loaderCalls++; return Task.FromResult(new List<int> { 2 }); });
|
cache.GetOrLoad("k", () => { loaderCalls++; return new List<int> { 2 }; });
|
||||||
|
|
||||||
loaderCalls.ShouldBe(2);
|
loaderCalls.ShouldBe(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MatchKeysCaseInsensitivelyWhenLookingUp()
|
public void MatchKeysCaseInsensitivelyWhenLookingUp()
|
||||||
{
|
{
|
||||||
var cache = new RepositoryCache();
|
var cache = new RepositoryCache();
|
||||||
|
|
||||||
await cache.GetOrLoadAsync("Path/To/File.xml", () => Task.FromResult(new List<int> { 1 }));
|
cache.GetOrLoad("Path/To/File.xml", () => new List<int> { 1 });
|
||||||
var upper = await cache.GetOrLoadAsync("PATH/TO/FILE.XML", () => Task.FromResult(new List<int> { 99 }));
|
var upper = cache.GetOrLoad("PATH/TO/FILE.XML", () => new List<int> { 99 });
|
||||||
|
|
||||||
upper.ShouldBe(new[] { 1 });
|
upper.ShouldBe(new[] { 1 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,12 @@ public class TransactionRepositoryTestShould : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PreserveValueWhenRoundTrippingNonFxTransaction()
|
public void PreserveValueWhenRoundTrippingNonFxTransaction()
|
||||||
{
|
{
|
||||||
accountRepo.GetAccountAsync("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
|
accountRepo.GetAccount("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
|
||||||
accountRepo.GetAccountAsync("groc").Returns(new Account { Id = "groc", Currency = "CHF" });
|
accountRepo.GetAccount("groc").Returns(new Account { Id = "groc", Currency = "CHF" });
|
||||||
|
|
||||||
await sut.SaveTransactionAsync(new Transaction
|
sut.SaveTransaction(new Transaction
|
||||||
{
|
{
|
||||||
OriginAccountId = "chk",
|
OriginAccountId = "chk",
|
||||||
TargetAccountId = "groc",
|
TargetAccountId = "groc",
|
||||||
|
|
@ -53,21 +53,22 @@ public class TransactionRepositoryTestShould : IDisposable
|
||||||
BookingDate = new DateTime(2026, 5, 10)
|
BookingDate = new DateTime(2026, 5, 10)
|
||||||
});
|
});
|
||||||
|
|
||||||
var stored = (await sut.GetTransactionListAsync()).Single();
|
var stored = sut.GetTransactionList().Single();
|
||||||
var loaded = await sut.GetTransactionAsync(stored.Id);
|
var loaded = sut.GetTransaction(stored.Id);
|
||||||
|
|
||||||
loaded.ShouldNotBeNull();
|
loaded.ShouldNotBeNull();
|
||||||
loaded.Value.ShouldBe(42m);
|
loaded.Value.ShouldBe(42m);
|
||||||
loaded.FxRate.ShouldBeNull();
|
loaded.FxRate.ShouldBeNull(); // Non-FX transactions have FxRate cleared
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StoreInChfAndReturnInAccountCurrencyWhenTransactionIsFx()
|
public void StoreInChfAndReturnInAccountCurrencyWhenTransactionIsFx()
|
||||||
{
|
{
|
||||||
accountRepo.GetAccountAsync("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
|
// User enters 100 USD at rate 0.9 CHF/USD -> stored as 90 CHF; read back as 100 USD.
|
||||||
accountRepo.GetAccountAsync("usd").Returns(new Account { Id = "usd", Currency = "USD" });
|
accountRepo.GetAccount("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
|
||||||
|
accountRepo.GetAccount("usd").Returns(new Account { Id = "usd", Currency = "USD" });
|
||||||
|
|
||||||
await sut.SaveTransactionAsync(new Transaction
|
sut.SaveTransaction(new Transaction
|
||||||
{
|
{
|
||||||
OriginAccountId = "chk",
|
OriginAccountId = "chk",
|
||||||
TargetAccountId = "usd",
|
TargetAccountId = "usd",
|
||||||
|
|
@ -78,28 +79,28 @@ public class TransactionRepositoryTestShould : IDisposable
|
||||||
BookingDate = new DateTime(2026, 5, 10)
|
BookingDate = new DateTime(2026, 5, 10)
|
||||||
});
|
});
|
||||||
|
|
||||||
var stored = (await sut.GetTransactionListAsync()).Single();
|
var stored = sut.GetTransactionList().Single();
|
||||||
stored.Value.ShouldBe(90m);
|
stored.Value.ShouldBe(90m); // stored in CHF
|
||||||
|
|
||||||
var loaded = await sut.GetTransactionAsync(stored.Id);
|
var loaded = sut.GetTransaction(stored.Id);
|
||||||
loaded.ShouldNotBeNull();
|
loaded.ShouldNotBeNull();
|
||||||
loaded.Value.ShouldBe(100m);
|
loaded.Value.ShouldBe(100m); // display currency
|
||||||
loaded.FxRate.ShouldBe(0.9m);
|
loaded.FxRate.ShouldBe(0.9m);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReturnNullWhenGettingUnknownTransactionId()
|
public void ReturnNullWhenGettingUnknownTransactionId()
|
||||||
{
|
{
|
||||||
(await sut.GetTransactionAsync("no-such-id")).ShouldBeNull();
|
sut.GetTransaction("no-such-id").ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task NotMutateStoredValueWhenGettingFxTransactionTwice()
|
public void NotMutateStoredValueWhenGettingFxTransactionTwice()
|
||||||
{
|
{
|
||||||
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" });
|
||||||
|
|
||||||
await sut.SaveTransactionAsync(new Transaction
|
sut.SaveTransaction(new Transaction
|
||||||
{
|
{
|
||||||
OriginAccountId = "chk",
|
OriginAccountId = "chk",
|
||||||
TargetAccountId = "usd",
|
TargetAccountId = "usd",
|
||||||
|
|
@ -110,11 +111,12 @@ public class TransactionRepositoryTestShould : IDisposable
|
||||||
BookingDate = new DateTime(2026, 5, 10)
|
BookingDate = new DateTime(2026, 5, 10)
|
||||||
});
|
});
|
||||||
|
|
||||||
var stored = (await sut.GetTransactionListAsync()).Single();
|
var stored = sut.GetTransactionList().Single();
|
||||||
var id = stored.Id;
|
var id = stored.Id;
|
||||||
|
|
||||||
var first = await sut.GetTransactionAsync(id);
|
// Get twice; if Get mutated the stored entity, the second call would divide by FxRate again.
|
||||||
var second = await sut.GetTransactionAsync(id);
|
var first = sut.GetTransaction(id);
|
||||||
|
var second = sut.GetTransaction(id);
|
||||||
|
|
||||||
first.ShouldNotBeNull();
|
first.ShouldNotBeNull();
|
||||||
first.Value.ShouldBe(100m);
|
first.Value.ShouldBe(100m);
|
||||||
|
|
@ -123,24 +125,24 @@ public class TransactionRepositoryTestShould : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RemoveTransactionWhenDeletingById()
|
public void RemoveTransactionWhenDeletingById()
|
||||||
{
|
{
|
||||||
accountRepo.GetAccountAsync(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
|
accountRepo.GetAccount(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
|
||||||
|
|
||||||
await sut.SaveTransactionAsync(new Transaction { OriginAccountId = "a", TargetAccountId = "b", Value = 1m, Text = "x", ValueDate = DateTime.Today });
|
sut.SaveTransaction(new Transaction { OriginAccountId = "a", TargetAccountId = "b", Value = 1m, Text = "x", ValueDate = DateTime.Today });
|
||||||
var id = (await sut.GetTransactionListAsync()).Single().Id;
|
var id = sut.GetTransactionList().Single().Id;
|
||||||
|
|
||||||
await sut.DeleteTransactionAsync(id);
|
sut.DeleteTransaction(id);
|
||||||
|
|
||||||
(await sut.GetTransactionListAsync()).ShouldBeEmpty();
|
sut.GetTransactionList().ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PreserveRelatedPartyWhenRoundTrippingTransaction()
|
public void PreserveRelatedPartyWhenRoundTrippingTransaction()
|
||||||
{
|
{
|
||||||
accountRepo.GetAccountAsync(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
|
accountRepo.GetAccount(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
|
||||||
|
|
||||||
await sut.SaveTransactionAsync(new Transaction
|
sut.SaveTransaction(new Transaction
|
||||||
{
|
{
|
||||||
OriginAccountId = "a",
|
OriginAccountId = "a",
|
||||||
TargetAccountId = "b",
|
TargetAccountId = "b",
|
||||||
|
|
@ -151,23 +153,26 @@ public class TransactionRepositoryTestShould : IDisposable
|
||||||
BookingDate = new DateTime(2026, 4, 1)
|
BookingDate = new DateTime(2026, 4, 1)
|
||||||
});
|
});
|
||||||
|
|
||||||
(await sut.GetTransactionListAsync()).Single().RelatedParty.ShouldBe("ACME Property AG");
|
// Regression: earlier Transaction.Copy dropped RelatedParty on every save,
|
||||||
|
// so the round-trip lost it.
|
||||||
|
sut.GetTransactionList().Single().RelatedParty.ShouldBe("ACME Property AG");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DefaultBookingDateToValueDateWhenBookingDateIsUnset()
|
public void DefaultBookingDateToValueDateWhenBookingDateIsUnset()
|
||||||
{
|
{
|
||||||
accountRepo.GetAccountAsync(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
|
accountRepo.GetAccount(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
|
||||||
|
|
||||||
await sut.SaveTransactionAsync(new Transaction
|
sut.SaveTransaction(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)
|
||||||
});
|
});
|
||||||
|
|
||||||
(await sut.GetTransactionListAsync()).Single().BookingDate.ShouldBe(new DateTime(2026, 4, 15));
|
sut.GetTransactionList().Single().BookingDate.ShouldBe(new DateTime(2026, 4, 15));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,12 @@ public class ViewServiceTestShould
|
||||||
|
|
||||||
public ViewServiceTestShould()
|
public ViewServiceTestShould()
|
||||||
{
|
{
|
||||||
subclassRepo.GetSubClassListAsync().Returns(
|
// Cover every subclass number a test might reference (Account.SubClass = Number / 100).
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
|
|
@ -32,16 +34,16 @@ public class ViewServiceTestShould
|
||||||
// --- Balance math ---------------------------------------------------------
|
// --- Balance math ---------------------------------------------------------
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SumActivaAndPassivaTotalsSeparatelyWhenGettingBalanceView()
|
public void 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.GetAccountListAsync().Returns(new List<Account> { cash, receivables, payables });
|
accountRepo.GetAccountList().Returns(new List<Account> { cash, receivables, payables });
|
||||||
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>());
|
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
|
||||||
|
|
||||||
var view = await BuildService().GetBalanceViewAsync();
|
var view = BuildService().GetBalanceView();
|
||||||
|
|
||||||
view.ActivaAccountList.Count.ShouldBe(2);
|
view.ActivaAccountList.Count.ShouldBe(2);
|
||||||
view.PassivaAccountList.Count.ShouldBe(1);
|
view.PassivaAccountList.Count.ShouldBe(1);
|
||||||
|
|
@ -50,105 +52,109 @@ public class ViewServiceTestShould
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ApplyTransactionsToStartBalanceWhenComputingAccountBalance()
|
public void 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.GetAccountListAsync().Returns(new List<Account> { cash, groceries });
|
accountRepo.GetAccountList().Returns(new List<Account> { cash, groceries });
|
||||||
|
|
||||||
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
|
// Debit cash 50, credit groceries 50 (spending)
|
||||||
|
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 = await BuildService().GetAccountViewListAsync();
|
var accounts = BuildService().GetAccountViewList();
|
||||||
|
|
||||||
accounts.Single(a => a.Id == "cash").Balance.ShouldBe(920m);
|
accounts.Single(a => a.Id == "cash").Balance.ShouldBe(920m); // 1000 - 80
|
||||||
accounts.Single(a => a.Id == "groc").Balance.ShouldBe(80m);
|
accounts.Single(a => a.Id == "groc").Balance.ShouldBe(80m);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ConvertBalanceToChfWhenAccountUsesForeignCurrency()
|
public void ConvertBalanceToChfWhenAccountUsesForeignCurrency()
|
||||||
{
|
{
|
||||||
var usd = Account("usd", 1050, "USD", startBalance: 100m);
|
var usd = Account("usd", 1050, "USD", startBalance: 100m);
|
||||||
accountRepo.GetAccountListAsync().Returns(new List<Account> { usd });
|
accountRepo.GetAccountList().Returns(new List<Account> { usd });
|
||||||
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>());
|
transactionRepo.GetTransactionList().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 = (await BuildService().GetAccountViewListAsync()).Single();
|
var account = BuildService().GetAccountViewList().Single();
|
||||||
|
|
||||||
account.Balance.ShouldBe(100m);
|
account.Balance.ShouldBe(100m); // account currency
|
||||||
account.BalanceCHF.ShouldBe(90m);
|
account.BalanceCHF.ShouldBe(90m); // FX-converted
|
||||||
account.StartBalanceCHF.ShouldBe(90m);
|
account.StartBalanceCHF.ShouldBe(90m);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Bank-transaction auto-matching --------------------------------------
|
// --- Bank-transaction auto-matching --------------------------------------
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MatchBankTransactionByBookingRuleWhenLookupTextIsContained()
|
public void 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.GetAccountListAsync().Returns(new List<Account> { checking, utilities });
|
accountRepo.GetAccountList().Returns(new List<Account> { checking, utilities });
|
||||||
bookingRuleRepo.GetBookingRuleListAsync().Returns(new List<BookingRule>
|
bookingRuleRepo.GetBookingRuleList().Returns(new List<BookingRule>
|
||||||
{
|
{
|
||||||
new() { Id = "r1", LookupText = "electric", AccountId = "util", BookingText = "Strom" }
|
new() { Id = "r1", LookupText = "electric", AccountId = "util", BookingText = "Strom" }
|
||||||
});
|
});
|
||||||
bankTransactionRepo.GetBankTransactionListAsync().Returns(new List<BankTransaction>
|
bankTransactionRepo.GetBankTransactionList().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.GetTransactionListAsync().Returns(new List<Transaction>());
|
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
|
||||||
|
|
||||||
var matched = (await BuildService().MatchOpenBankTransactionsAsync()).Single();
|
var matched = BuildService().MatchOpenBankTransactions().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 async Task PreferBookingRuleWithMatchingValueWhenMultipleRulesMatchText()
|
public void 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.GetAccountListAsync().Returns(new List<Account> { checking, rent, groceries });
|
accountRepo.GetAccountList().Returns(new List<Account> { checking, rent, groceries });
|
||||||
bookingRuleRepo.GetBookingRuleListAsync().Returns(new List<BookingRule>
|
bookingRuleRepo.GetBookingRuleList().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.GetBankTransactionListAsync().Returns(new List<BankTransaction>
|
bankTransactionRepo.GetBankTransactionList().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.GetTransactionListAsync().Returns(new List<Transaction>());
|
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
|
||||||
|
|
||||||
var matched = (await BuildService().MatchOpenBankTransactionsAsync()).Single();
|
var matched = BuildService().MatchOpenBankTransactions().Single();
|
||||||
|
|
||||||
matched.Text.ShouldBe("Rent via Coop");
|
matched.Text.ShouldBe("Rent via Coop");
|
||||||
matched.TargetAccountId.ShouldBe("rent");
|
matched.TargetAccountId.ShouldBe("rent");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FallBackToSameAccountsLastMonthWhenNoBookingRuleMatches()
|
public void 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.GetAccountListAsync().Returns(new List<Account> { checking, salary });
|
accountRepo.GetAccountList().Returns(new List<Account> { checking, salary });
|
||||||
bookingRuleRepo.GetBookingRuleListAsync().Returns(new List<BookingRule>());
|
bookingRuleRepo.GetBookingRuleList().Returns(new List<BookingRule>());
|
||||||
bankTransactionRepo.GetBankTransactionListAsync().Returns(new List<BankTransaction>
|
bankTransactionRepo.GetBankTransactionList().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) }
|
||||||
});
|
});
|
||||||
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
|
// A prior-month booking with same accounts + value should be picked up
|
||||||
|
transactionRepo.GetTransactionList().Returns(new List<Transaction>
|
||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
|
|
@ -162,31 +168,33 @@ public class ViewServiceTestShould
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
var matched = (await BuildService().MatchOpenBankTransactionsAsync()).Single();
|
var matched = BuildService().MatchOpenBankTransactions().Single();
|
||||||
|
|
||||||
matched.Text.ShouldBe("Monatslohn");
|
matched.Text.ShouldBe("Monatslohn");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ExcludeIgnoredAndAlreadyBookedWhenGettingOpenBankTransactions()
|
public void ExcludeIgnoredAndAlreadyBookedWhenGettingOpenBankTransactions()
|
||||||
{
|
{
|
||||||
bankTransactionRepo.GetBankTransactionListAsync().Returns(new List<BankTransaction>
|
bankTransactionRepo.GetBankTransactionList().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.GetTransactionListAsync().Returns(new List<Transaction>
|
transactionRepo.GetTransactionList().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 = await BuildService().GetOpenBankTransactionListAsync();
|
var open = BuildService().GetOpenBankTransactionList();
|
||||||
|
|
||||||
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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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 Accounts : ComponentBase
|
||||||
|
|
||||||
private IQueryable<Account>? accountQueryable;
|
private IQueryable<Account>? accountQueryable;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
accountQueryable = (await accountRepository.GetAccountListAsync()).AsQueryable();
|
accountQueryable = accountRepository.GetAccountList().AsQueryable();
|
||||||
await base.OnInitializedAsync();
|
return 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 = (await accountRepository.GetAccountListAsync()).AsQueryable();
|
accountQueryable = accountRepository.GetAccountList().AsQueryable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EditAsync(string id)
|
private async Task EditAsync(string id)
|
||||||
{
|
{
|
||||||
var data = await accountRepository.GetAccountAsync(id);
|
var data = accountRepository.GetAccount(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 = (await accountRepository.GetAccountListAsync()).AsQueryable();
|
accountQueryable = accountRepository.GetAccountList().AsQueryable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteAsync(string id)
|
private async Task DeleteAsync(string id)
|
||||||
{
|
{
|
||||||
var account = await accountRepository.GetAccountAsync(id);
|
var account = accountRepository.GetAccount(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)
|
||||||
{
|
{
|
||||||
await accountRepository.DeleteAccountAsync(id);
|
accountRepository.DeleteAccount(id);
|
||||||
accountQueryable = (await accountRepository.GetAccountListAsync()).AsQueryable();
|
accountQueryable = accountRepository.GetAccountList().AsQueryable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 async Task OnInitializedAsync()
|
protected override Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
TransactionList = (await bankTransactionRepository.GetBankTransactionListAsync()).AsQueryable();
|
TransactionList = bankTransactionRepository.GetBankTransactionList().AsQueryable();
|
||||||
await base.OnInitializedAsync();
|
return base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 async Task OnInitializedAsync()
|
protected override Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
bookingRulesQueryable = (await viewService.GetBookingRuleViewListAsync()).AsQueryable();
|
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable();
|
||||||
await base.OnInitializedAsync();
|
return 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 = (await viewService.GetBookingRuleViewListAsync()).AsQueryable();
|
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EditAsync(string id)
|
private async Task EditAsync(string id)
|
||||||
{
|
{
|
||||||
var data = await bookingRuleRepository.GetBookingRuleAsync(id);
|
var data = bookingRuleRepository.GetBookingRule(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 = (await viewService.GetBookingRuleViewListAsync()).AsQueryable();
|
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteAsync(string id)
|
private async Task DeleteAsync(string id)
|
||||||
{
|
{
|
||||||
var bookingRule = await bookingRuleRepository.GetBookingRuleAsync(id);
|
var bookingRule = bookingRuleRepository.GetBookingRule(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)
|
||||||
{
|
{
|
||||||
await bookingRuleRepository.DeleteBookingRuleAsync(id);
|
bookingRuleRepository.DeleteBookingRule(id);
|
||||||
bookingRulesQueryable = (await viewService.GetBookingRuleViewListAsync()).AsQueryable();
|
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 async Task OnInitializedAsync()
|
protected override Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
bookingTextQueryable = (await bookingTextRepository.GetBookingTextListAsync()).AsQueryable();
|
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable();
|
||||||
await base.OnInitializedAsync();
|
return 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 = (await bookingTextRepository.GetBookingTextListAsync()).AsQueryable();
|
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EditAsync(string id)
|
private async Task EditAsync(string id)
|
||||||
{
|
{
|
||||||
var data = await bookingTextRepository.GetBookingTextAsync(id);
|
var data = bookingTextRepository.GetBookingText(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 = (await bookingTextRepository.GetBookingTextListAsync()).AsQueryable();
|
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteAsync(string id)
|
private async Task DeleteAsync(string id)
|
||||||
{
|
{
|
||||||
var bookingText = await bookingTextRepository.GetBookingTextAsync(id);
|
var bookingText = bookingTextRepository.GetBookingText(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)
|
||||||
{
|
{
|
||||||
await bookingTextRepository.DeleteBookingTextAsync(id);
|
bookingTextRepository.DeleteBookingText(id);
|
||||||
bookingTextQueryable = (await bookingTextRepository.GetBookingTextListAsync()).AsQueryable();
|
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,7 +3,4 @@
|
||||||
|
|
||||||
<PageTitle>Ausgaben</PageTitle>
|
<PageTitle>Ausgaben</PageTitle>
|
||||||
|
|
||||||
@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" />
|
||||||
{
|
|
||||||
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
private Config config = null!;
|
||||||
private Plotly.Blazor.Layout? layout;
|
private Plotly.Blazor.Layout layout = null!;
|
||||||
private IList<ITrace>? data;
|
private IList<ITrace> data = null!;
|
||||||
|
|
||||||
[Inject]
|
[Inject]
|
||||||
private IViewService viewService { get; set; } = null!;
|
private IViewService viewService { get; set; } = null!;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
config = new Config
|
config = new Config
|
||||||
{
|
{
|
||||||
|
|
@ -26,7 +26,7 @@ public partial class Assets : ComponentBase
|
||||||
{
|
{
|
||||||
};
|
};
|
||||||
|
|
||||||
var accounts = (await viewService.GetAccountViewListAsync()).Where(a => a.Class == ClassIds.Activa);
|
var accounts = viewService.GetAccountViewList().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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await base.OnInitializedAsync();
|
return base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,7 +3,4 @@
|
||||||
|
|
||||||
<PageTitle>Ausgaben</PageTitle>
|
<PageTitle>Ausgaben</PageTitle>
|
||||||
|
|
||||||
@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" />
|
||||||
{
|
|
||||||
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
private Config config = null!;
|
||||||
private Plotly.Blazor.Layout? layout;
|
private Plotly.Blazor.Layout layout = null!;
|
||||||
private IList<ITrace>? data;
|
private IList<ITrace> data = null!;
|
||||||
|
|
||||||
[Inject]
|
[Inject]
|
||||||
private IViewService viewService { get; set; } = null!;
|
private IViewService viewService { get; set; } = null!;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
config = new Config
|
config = new Config
|
||||||
{
|
{
|
||||||
|
|
@ -26,7 +26,7 @@ public partial class Spendings : ComponentBase
|
||||||
{
|
{
|
||||||
};
|
};
|
||||||
|
|
||||||
var accounts = (await viewService.GetAccountViewListAsync()).Where(a => a.Class == ClassIds.Expenses);
|
var accounts = viewService.GetAccountViewList().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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await base.OnInitializedAsync();
|
return base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,7 +3,4 @@
|
||||||
|
|
||||||
<PageTitle>Ausgaben im Verlauf</PageTitle>
|
<PageTitle>Ausgaben im Verlauf</PageTitle>
|
||||||
|
|
||||||
@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" />
|
||||||
{
|
|
||||||
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
private Config config = null!;
|
||||||
private Plotly.Blazor.Layout? layout;
|
private Plotly.Blazor.Layout layout = null!;
|
||||||
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 async Task OnInitializedAsync()
|
protected override Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
config = new Config
|
config = new Config
|
||||||
{
|
{
|
||||||
|
|
@ -44,7 +44,7 @@ public partial class SpendingsOverTime : ComponentBase
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
var dataSeries = await chartService.GetExpensesPerMonthAsync();
|
var dataSeries = chartService.GetExpensesPerMonth();
|
||||||
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
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
await base.OnInitializedAsync();
|
return base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 async Task OnInitializedAsync()
|
protected override Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
|
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable();
|
||||||
await base.OnInitializedAsync();
|
return 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 = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
|
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EditAsync(string id)
|
private async Task EditAsync(string id)
|
||||||
{
|
{
|
||||||
var data = await subclassRepository.GetSubClassAsync(id);
|
var data = subclassRepository.GetSubClass(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 = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
|
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteAsync(string id)
|
private async Task DeleteAsync(string id)
|
||||||
{
|
{
|
||||||
var subclass = await subclassRepository.GetSubClassAsync(id);
|
var subclass = subclassRepository.GetSubClass(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)
|
||||||
{
|
{
|
||||||
await subclassRepository.DeleteSubClassAsync(id);
|
subclassRepository.DeleteSubClass(id);
|
||||||
subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
|
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -28,7 +28,7 @@ public partial class AccountDialog : ComponentBase
|
||||||
{
|
{
|
||||||
if (editContext.Validate())
|
if (editContext.Validate())
|
||||||
{
|
{
|
||||||
await accountRepository.SaveAccountAsync(Content);
|
accountRepository.SaveAccount(Content);
|
||||||
await Dialog.CloseAsync(Content);
|
await Dialog.CloseAsync(Content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,6 @@
|
||||||
</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;"/>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ namespace Schaad.Accounting.UI.Components.Pages.Dialogs;
|
||||||
|
|
||||||
public partial class BookingRuleDialog : ComponentBase
|
public partial class BookingRuleDialog : ComponentBase
|
||||||
{
|
{
|
||||||
private EditContext? editContext;
|
private EditContext editContext = null!;
|
||||||
|
|
||||||
[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 async Task OnInitializedAsync()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
editContext = new EditContext(Content);
|
editContext = new EditContext(Content);
|
||||||
accounts = await viewService.GetAccountViewListAsync();
|
accounts = viewService.GetAccountViewList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveAsync()
|
private async Task SaveAsync()
|
||||||
{
|
{
|
||||||
if (editContext.Validate())
|
if (editContext.Validate())
|
||||||
{
|
{
|
||||||
await bookingRuleRepository.SaveBookingRuleAsync(Content);
|
bookingRuleRepository.SaveBookingRule(Content);
|
||||||
await Dialog.CloseAsync(Content);
|
await Dialog.CloseAsync(Content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ public partial class BookingTextDialog : ComponentBase
|
||||||
{
|
{
|
||||||
if (editContext.Validate())
|
if (editContext.Validate())
|
||||||
{
|
{
|
||||||
await bookingTextRepository.SaveBookingTextAsync(Content);
|
bookingTextRepository.SaveBookingText(Content);
|
||||||
await Dialog.CloseAsync(Content);
|
await Dialog.CloseAsync(Content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ public partial class ClassDialog : ComponentBase
|
||||||
{
|
{
|
||||||
if (editContext.Validate())
|
if (editContext.Validate())
|
||||||
{
|
{
|
||||||
await bookingRuleRepository.SaveSubClassAsync(Content);
|
bookingRuleRepository.SaveSubClass(Content);
|
||||||
await Dialog.CloseAsync(Content);
|
await Dialog.CloseAsync(Content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,6 @@
|
||||||
</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;"/>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ namespace Schaad.Accounting.UI.Components.Pages.Dialogs;
|
||||||
|
|
||||||
public partial class TransactionDialog : ComponentBase
|
public partial class TransactionDialog : ComponentBase
|
||||||
{
|
{
|
||||||
private EditContext? editContext;
|
private EditContext editContext = null!;
|
||||||
|
|
||||||
[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 async Task OnInitializedAsync()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
SelectedValue = Content.ValueDate;
|
SelectedValue = Content.ValueDate;
|
||||||
editContext = new EditContext(Content);
|
editContext = new EditContext(Content);
|
||||||
accounts = await viewService.GetAccountViewListAsync();
|
accounts = viewService.GetAccountViewList();
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
await transactionRepository.SaveTransactionAsync(Content);
|
transactionRepository.SaveTransaction(Content);
|
||||||
await Dialog.CloseAsync(Content);
|
await Dialog.CloseAsync(Content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,6 @@
|
||||||
</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">
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ namespace Schaad.Accounting.UI.Components.Pages.Dialogs;
|
||||||
|
|
||||||
public partial class TransactionSplitDialog : ComponentBase
|
public partial class TransactionSplitDialog : ComponentBase
|
||||||
{
|
{
|
||||||
private EditContext? editContext;
|
private EditContext editContext = null!;
|
||||||
|
|
||||||
[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 = [];
|
private IReadOnlyList<AccountDataset> accounts = null!;
|
||||||
private IReadOnlyList<string> bookingTexts = [];
|
private IReadOnlyList<string> bookingTexts = [];
|
||||||
private decimal openAmount = 0;
|
private decimal openAmount = 0;
|
||||||
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
accounts = await viewService.GetAccountViewListAsync();
|
accounts = viewService.GetAccountViewList();
|
||||||
bookingTexts = (await bookingTextRepository.GetBookingTextListAsync()).Select(b => b.Text).ToArray();
|
bookingTexts = bookingTextRepository.GetBookingTextList().Select(b => b.Text).ToArray();
|
||||||
var accountList = await accountRepository.GetAccountListAsync();
|
var accountList = accountRepository.GetAccountList();
|
||||||
var bankTrx = await bankTransactionRepository.GetBankTransactionAsync(Content.BankTransactionId);
|
var bankTrx = bankTransactionRepository.GetBankTransaction(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)
|
||||||
{
|
{
|
||||||
await transactionRepository.SaveTransactionAsync(transtaction);
|
transactionRepository.SaveTransaction(transtaction);
|
||||||
}
|
}
|
||||||
await Dialog.CloseAsync(Content);
|
await Dialog.CloseAsync(Content);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 async Task OnInitializedAsync()
|
protected override Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
matchingBankTransactions = await viewService.MatchOpenBankTransactionsAsync();
|
matchingBankTransactions = viewService.MatchOpenBankTransactions();
|
||||||
accounts = await viewService.GetAccountViewListAsync();
|
accounts = viewService.GetAccountViewList();
|
||||||
bookingTexts = (await bookingTextRepository.GetBookingTextListAsync()).Select(b => b.Text).ToArray();
|
bookingTexts = bookingTextRepository.GetBookingTextList().Select(b => b.Text).ToArray();
|
||||||
await base.OnInitializedAsync();
|
return 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 = await viewService.MatchOpenBankTransactionsAsync();
|
matchingBankTransactions = viewService.MatchOpenBankTransactions();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ImportXmlAndShowResultAsync(string fileName)
|
private async Task ImportXmlAndShowResultAsync(string fileName)
|
||||||
{
|
{
|
||||||
var messages = await fileService.ImportAccountStatementFileAsync(fileName);
|
var messages = fileService.ImportAccountStatementFile(fileName);
|
||||||
foreach (var message in messages)
|
foreach (var message in messages)
|
||||||
{
|
{
|
||||||
await ShowImportResultAsync(message);
|
await ShowImportResultAsync(message);
|
||||||
|
|
@ -156,8 +156,9 @@ 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;
|
||||||
}
|
}
|
||||||
await transactionRepository.SaveTransactionAsync(transaction);
|
transactionRepository.SaveTransaction(transaction);
|
||||||
transaction.ReadOnly = true;
|
transaction.ReadOnly = true;
|
||||||
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SplitBankTransactionAsync(Transaction transaction)
|
private async Task SplitBankTransactionAsync(Transaction transaction)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
@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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
private BalanceDataset balance = null!;
|
||||||
private decimal win;
|
private decimal win;
|
||||||
private string header = "";
|
private string header = null!;
|
||||||
private string footer = "";
|
private string footer = null!;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
balance = await viewService.GetBalanceViewAsync();
|
balance = viewService.GetBalanceView();
|
||||||
win = balance.TotalActivaCHF - balance.TotalPassivaCHF;
|
win = balance.TotalActivaCHF - balance.TotalPassivaCHF;
|
||||||
(header, footer) = Report.GetViewDataTitleAndFooter("Bilanz", settingsService);
|
(header, footer) = Report.GetViewDataTitleAndFooter("Bilanz", settingsService);
|
||||||
|
|
||||||
await base.OnInitializedAsync();
|
return base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
@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 />
|
||||||
|
|
|
||||||
|
|
@ -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;
|
private BalanceSheetDataset balanceSheet = null!;
|
||||||
private decimal win;
|
private decimal win;
|
||||||
private string header = "";
|
private string header = null!;
|
||||||
private string footer = "";
|
private string footer = null!;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
balanceSheet = await viewService.GetBalanceSheetViewAsync(settingsService.GetYear());
|
balanceSheet = viewService.GetBalanceSheetView(settingsService.GetYear());
|
||||||
win = balanceSheet.ProfitCHF - balanceSheet.LossCHF;
|
win = balanceSheet.ProfitCHF - balanceSheet.LossCHF;
|
||||||
(header, footer) = Report.GetViewDataTitleAndFooter("Jahresabschluss", settingsService);
|
(header, footer) = Report.GetViewDataTitleAndFooter("Jahresabschluss", settingsService);
|
||||||
|
|
||||||
await base.OnInitializedAsync();
|
return base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
@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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
private IReadOnlyList<AccountDataset> accounts = null!;
|
||||||
private IReadOnlyList<TransactionDataset>? transactions;
|
private IReadOnlyList<TransactionDataset> transactions = null!;
|
||||||
private decimal profit;
|
private decimal profit;
|
||||||
private decimal loss;
|
private decimal loss;
|
||||||
private string header = "";
|
private string header = null!;
|
||||||
private string footer = "";
|
private string footer = null!;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
accounts = await viewService.GetAccountViewListAsync();
|
accounts = viewService.GetAccountViewList();
|
||||||
transactions = await viewService.GetTransactionViewListAsync();
|
transactions = viewService.GetTransactionViewList();
|
||||||
|
|
||||||
profit = Math.Abs(accounts.Where(m => m.Class == ClassIds.Income).Sum(m => m.Balance));
|
profit = Math.Abs(accounts.Where(m => m.Class == 3).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 == 4).Sum(m => m.Balance));
|
||||||
|
|
||||||
(header, footer) = Report.GetViewDataTitleAndFooter("Detailaufstellung", settingsService);
|
(header, footer) = Report.GetViewDataTitleAndFooter("Detailaufstellung", settingsService);
|
||||||
|
|
||||||
await base.OnInitializedAsync();
|
return base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
@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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
private IReadOnlyList<AccountDataset> accounts = null!;
|
||||||
private decimal profit;
|
private decimal profit;
|
||||||
private decimal loss;
|
private decimal loss;
|
||||||
private decimal win;
|
private decimal win;
|
||||||
private string header = "";
|
private string header = null!;
|
||||||
private string footer = "";
|
private string footer = null!;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
accounts = await viewService.GetAccountViewListAsync();
|
accounts = viewService.GetAccountViewList();
|
||||||
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);
|
||||||
|
|
||||||
await base.OnInitializedAsync();
|
return base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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="() => ShowTransactionsAsync(account.Id)" Icon="@(new Icons.Regular.Size24.CalendarAgenda())">
|
<FluentNavLink OnClick="() => ShowTransactions(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,8 +29,6 @@
|
||||||
</FluentNavMenu>
|
</FluentNavMenu>
|
||||||
|
|
||||||
<FluentBodyContent>
|
<FluentBodyContent>
|
||||||
@if (selectedAccount is not null)
|
|
||||||
{
|
|
||||||
<FluentGrid Justify="JustifyContent.FlexStart">
|
<FluentGrid Justify="JustifyContent.FlexStart">
|
||||||
<FluentGridItem xs="4" Class="align-content-center title">
|
<FluentGridItem xs="4" Class="align-content-center title">
|
||||||
@selectedAccount.Name
|
@selectedAccount.Name
|
||||||
|
|
@ -61,7 +59,6 @@
|
||||||
<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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = [];
|
private IReadOnlyList<AccountDataset> accounts = null!;
|
||||||
|
|
||||||
private IQueryable<TransactionDataset>? transactionList;
|
private IQueryable<TransactionDataset>? transactionList;
|
||||||
private string? selectedAccountId;
|
private string selectedAccountId = null!;
|
||||||
private AccountDataset? selectedAccount;
|
private AccountDataset selectedAccount = null!;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
accounts = await viewService.GetAccountViewListAsync();
|
accounts = viewService.GetAccountViewList();
|
||||||
await ShowTransactionsAsync(accounts.First().Id);
|
ShowTransactions(accounts.First().Id);
|
||||||
await base.OnInitializedAsync();
|
return base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ShowTransactionsAsync(string accountId)
|
private void ShowTransactions(string accountId)
|
||||||
{
|
{
|
||||||
selectedAccountId = accountId;
|
selectedAccountId = accountId;
|
||||||
selectedAccount = accounts.Single(a => a.Id == accountId);
|
selectedAccount = accounts.Single(a => a.Id == accountId);
|
||||||
transactionList = (await viewService.GetTransactionViewListAsync(accountId)).AsQueryable();
|
transactionList = viewService.GetTransactionViewList(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 = (await viewService.GetTransactionViewListAsync(selectedAccountId)).AsQueryable();
|
transactionList = viewService.GetTransactionViewList(selectedAccountId).AsQueryable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EditAsync(string id)
|
private async Task EditAsync(string id)
|
||||||
{
|
{
|
||||||
var data = await transactionRepository.GetTransactionAsync(id);
|
var data = transactionRepository.GetTransaction(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 = (await viewService.GetTransactionViewListAsync(selectedAccountId)).AsQueryable();
|
transactionList = viewService.GetTransactionViewList(selectedAccountId).AsQueryable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteAsync(string id)
|
private async Task DeleteAsync(string id)
|
||||||
{
|
{
|
||||||
var transaction = await transactionRepository.GetTransactionAsync(id);
|
var transaction = transactionRepository.GetTransaction(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)
|
||||||
{
|
{
|
||||||
await transactionRepository.DeleteTransactionAsync(id);
|
transactionRepository.DeleteTransaction(id);
|
||||||
transactionList = (await viewService.GetTransactionViewListAsync(selectedAccountId)).AsQueryable();
|
transactionList = viewService.GetTransactionViewList(selectedAccountId).AsQueryable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue