Compare commits

..

3 commits

Author SHA1 Message Date
7236434f8a Reorder columns 2025-06-28 11:25:37 +02:00
b46d0fe5f1 Cleanup CSS 2025-06-28 11:23:33 +02:00
3d3a6af103 Make line readonly after save 2025-06-28 11:18:51 +02:00
120 changed files with 1689 additions and 4263 deletions

3
.gitignore vendored
View file

@ -41,9 +41,6 @@ ScaffoldingReadMe.txt
*~
CodeCoverage/
# JetBrains Rider / ReSharper user-specific settings
*.DotSettings.user
# MSBuild Binary and Structured Log
*.binlog

View file

@ -11,78 +11,28 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Services"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Db", "Schaad.Accounting.Db\Schaad.Accounting.Db.csproj", "{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Tests", "Schaad.Accounting.Tests\Schaad.Accounting.Tests.csproj", "{C0A48984-3770-49D7-9371-25351B4E79FF}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|Any CPU.Build.0 = Debug|Any CPU
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|x64.ActiveCfg = Debug|Any CPU
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|x64.Build.0 = Debug|Any CPU
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|x86.ActiveCfg = Debug|Any CPU
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|x86.Build.0 = Debug|Any CPU
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|Any CPU.ActiveCfg = Release|Any CPU
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|Any CPU.Build.0 = Release|Any CPU
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|x64.ActiveCfg = Release|Any CPU
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|x64.Build.0 = Release|Any CPU
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|x86.ActiveCfg = Release|Any CPU
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|x86.Build.0 = Release|Any CPU
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|x64.ActiveCfg = Debug|Any CPU
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|x64.Build.0 = Debug|Any CPU
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|x86.ActiveCfg = Debug|Any CPU
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|x86.Build.0 = Debug|Any CPU
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|Any CPU.Build.0 = Release|Any CPU
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|x64.ActiveCfg = Release|Any CPU
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|x64.Build.0 = Release|Any CPU
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|x86.ActiveCfg = Release|Any CPU
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|x86.Build.0 = Release|Any CPU
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|x64.ActiveCfg = Debug|Any CPU
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|x64.Build.0 = Debug|Any CPU
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|x86.ActiveCfg = Debug|Any CPU
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|x86.Build.0 = Debug|Any CPU
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|Any CPU.Build.0 = Release|Any CPU
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|x64.ActiveCfg = Release|Any CPU
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|x64.Build.0 = Release|Any CPU
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|x86.ActiveCfg = Release|Any CPU
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|x86.Build.0 = Release|Any CPU
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|x64.ActiveCfg = Debug|Any CPU
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|x64.Build.0 = Debug|Any CPU
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|x86.ActiveCfg = Debug|Any CPU
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|x86.Build.0 = Debug|Any CPU
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|Any CPU.Build.0 = Release|Any CPU
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|x64.ActiveCfg = Release|Any CPU
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|x64.Build.0 = Release|Any CPU
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|x86.ActiveCfg = Release|Any CPU
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|x86.Build.0 = Release|Any CPU
{C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|x64.ActiveCfg = Debug|Any CPU
{C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|x64.Build.0 = Debug|Any CPU
{C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|x86.ActiveCfg = Debug|Any CPU
{C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|x86.Build.0 = Debug|Any CPU
{C0A48984-3770-49D7-9371-25351B4E79FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C0A48984-3770-49D7-9371-25351B4E79FF}.Release|Any CPU.Build.0 = Release|Any CPU
{C0A48984-3770-49D7-9371-25351B4E79FF}.Release|x64.ActiveCfg = Release|Any CPU
{C0A48984-3770-49D7-9371-25351B4E79FF}.Release|x64.Build.0 = Release|Any CPU
{C0A48984-3770-49D7-9371-25351B4E79FF}.Release|x86.ActiveCfg = Release|Any CPU
{C0A48984-3770-49D7-9371-25351B4E79FF}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View file

@ -1,92 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Build
dotnet build
# Run (dev server at http://localhost:5225)
cd Schaad.Accounting.UI && dotnet run
# Test
dotnet test
# Publish
dotnet publish -c Release
```
Tests live in `Schaad.Accounting.Tests` (xUnit + NSubstitute + Shouldly). There is no lint command.
## Architecture
**AccountingNext** is a Swiss personal accounting app built with ASP.NET Core 9 + Blazor (interactive server-side rendering) and Microsoft Fluent UI. Data is stored as XML files on disk — no database.
### Projects
| Project | Role |
|---|---|
| `Schaad.Accounting.UI` | Blazor web app — pages, dialogs, layout |
| `Schaad.Accounting.Services` | Business logic — balances, FX conversion, file import, charts |
| `Schaad.Accounting.Db` | Repository layer — XML serialization/deserialization |
| `Schaad.Accounting.Common` | Shared models, DTOs, interfaces |
### Data flow
```
Blazor Pages/Dialogs (UI)
→ Services (ViewService, FileService, ChartService)
→ Repositories (AccountRepository, TransactionRepository, …)
→ XML files: {DataPath}/{year}/{mandator}/Data/*.xml
```
Data is multi-year and multi-mandator. Each combination has its own directory under `DataPath` (configured in `appsettings.Development.json`). When a new year is opened, the previous year's data is copied as the starting point.
### Key services
- **ViewService** — aggregates account balances, applies FX rates, builds datasets for the UI
- **FileService** — imports bank statements (MT940, CAMT053 via external `Schaad.Finance.dll`), parses PDFs, manages backups
- **ChartService** — produces data for Plotly charts (assets, spendings over time)
- **SettingsService** — manages the active year, mandator, and file paths
### UI structure
Pages live in `Schaad.Accounting.UI/Components/Pages/`. Each page typically has a companion `Dialogs/` subfolder with Fluent UI dialog components for CRUD operations. The app is hardcoded to the `de-CH` culture.
### Key external dependencies
- `Microsoft.FluentUI.AspNetCore.Components` — UI components (FluentDataGrid, FluentDialog, etc.)
- `Plotly.Blazor` — charts
- `FreeSpire.PDF` — PDF parsing for bank statement imports
- `Schaad.Finance.dll` / `Schaad.Finance.Api.dll` — proprietary DLLs for MT940/CAMT053 parsing and FX rate lookups (FixerIo API key in `appsettings.Development.json`)
### Domain constants
`ClassIds` in `Schaad.Accounting.Common` defines the Swiss accounting chart-of-accounts classes: Activa=1, Passiva=2, Income=3, Expenses=4. These are the leading digit of an account number (accounts are 4-digit; `Account.Class = Number / 1000`).
## Testing conventions
Tests live in `Schaad.Accounting.Tests` and use **xUnit** + **NSubstitute** (mocks) + **Shouldly** (assertions).
- **File and class name**: `<Subject>TestShould` — one file per subject under test. Examples: `ViewServiceTestShould.cs`, `TransactionRepositoryTestShould.cs`, `FormattingTestShould.cs`.
- **Test method name**: `DoThisWhenThat` — describes the behavior first, then the condition. Read together the class + method form a sentence:
- `ViewServiceTestShould.SumActivaAndPassivaTotalsSeparatelyWhenGettingBalanceView`
- `TransactionRepositoryTestShould.ReturnNullWhenGettingUnknownTransactionId`
- **Assertions**: use Shouldly (`value.ShouldBe(expected)`, `list.ShouldBeEmpty()`, `x.ShouldBeNull()`, `list.ShouldContain(...)`, `first.ShouldBeSameAs(second)`, etc.). Do **not** use xUnit `Assert.*`.
Example:
```csharp
public class FormattingTestShould
{
[Fact]
public void RoundToTwoDecimalsWhenValueHasMorePrecision()
{
1.234m.ToFormattedString().ShouldBe("1.23");
}
}
```
Repository tests that hit real XML I/O use a temp directory via `Path.GetTempPath()` and clean up in `IDisposable.Dispose`. Pure service tests use NSubstitute mocks for every dependency and don't touch the filesystem.

View file

@ -1,114 +0,0 @@
# AccountingNext — Technical Improvement Plan
Analysis and phased plan produced 2026-07-02. See conversation history for full context.
## Findings
### 1. Correctness bugs (must fix)
- **Shared mutable state across users.** `SettingsService` is registered as `Singleton` (`Schaad.Accounting.UI/Extensions.cs:21`) but holds mutable `year`/`mandator` fields (`Schaad.Accounting.Services/SettingsService.cs:13-14`). Any user switching year/mandator changes it for *all* connected Blazor Server users.
- **`TrySetYear` rollback is broken.** `SettingsService.cs:33` captures `var oldYear = year;` — that's the *parameter*, not `this.year`. The "rollback" restores the same value that just failed.
- **`ChartService` mutates a shared service.** `GetAccountExpensesPerMonth(accountId, year)` (`ChartService.cs:87`) calls `settingsService.TrySetYear(year)` then `SetYear(DateTime.Now.Year)` to reload prior-year data. Global mutation on a singleton — guaranteed to race in production.
- **`DummyFxService` has a typo.** `DummyFxService.cs:13` — `currencies.Contains(fromCurrency) && currencies.Contains(fromCurrency)` (both sides check `fromCurrency`). Also it silently returns the raw amount for supported currencies, so all "CHF conversions" are no-ops.
- **`TransactionRepository.GetTransaction` mutates the entity.** `TransactionRepository.cs:67-81` divides `Value` by `FxRate` on read, and `SaveTransaction` multiplies by `FxRate` on write. Also silently NREs when the id is not found (line 73 dereferences `transaction`).
- **`ViewService.GetTransactionViewList(accountId)` mutates transactions.** `ViewService.cs:172` flips `t.Value *= -1` on the loaded transaction. `FileService.GetTransactionListCsv` does the same (`FileService.cs:137`). These are the shared objects returned by the repository — later reads see wrong signs.
- **DI double-registration.** `Extensions.cs` registers `IChartService`, `IViewService`, `IFileService`, statement services, and `IFxService` in both `AddRepositories` and `AddServices`. Also `AddServices` registers `PdfParsingService` as its own key (`services.AddSingleton<PdfParsingService, PdfParsingService>()`) instead of `IPdfParsingService`.
- **Magic numbers instead of `ClassIds`.** `ProfitLossReport.razor.cs:25-26` uses `.Class == 3` / `.Class == 4`.
- **`ClassIds` doc/code mismatch.** `CLAUDE.md` states `Activa=1000, Passiva=2000, …`, but `ClassIds.cs` defines them as `1,2,3,4` (matches `Account.Class = Number / 1000`).
- **Non-atomic XML writes, no locking.** `BaseRepository.Save` writes directly to the target path. Interrupted writes corrupt data. Two concurrent saves interleave. No temp-file+rename, no `FileShare` lock.
- **`AccountRepository.EnsureFileExisits` calls `SaveAccount` in a loop.** Each call re-loads and re-serializes the whole account list.
- **`ProfitLossReport` / `BalanceSheetReport`** compute totals by re-summing balances that `ViewService` already computes — but with subtle differences. Two sources of truth for the same number.
### 2. Reliability / maintainability
- **No async I/O.** Every XML read/write is synchronous, blocking the SignalR hub thread in Blazor Server.
- **No caching.** A single page render calls `GetAccountList()` and `GetTransactionList()` many times. Every one is a fresh file read + XML deserialization.
- **O(N·M) lookups.** `ViewService.cs:138-139` — `accountList.Single(...)` inside a `Select` over all transactions. Should be a dictionary keyed by id.
- **No logging.** `ILogger` is not used anywhere.
- **No error surface.** Repositories throw or return `null`; UI dereferences with `!`.
- **Anemic domain model + `Copy` methods.** `Account.Copy`, `Transaction.Copy`, `SubClass.Copy` etc. are hand-rolled property copies used to merge edits into loaded entities.
- **Vendor coupling in the Domain project.** `Schaad.Accounting.Common` references `Schaad.Finance.Api.dll`. `IChartService`, `IFileService`, and `IViewService` live in `Common` and depend on `Schaad.Finance.Api` types.
- **Duplicated formatting logic.** `ToFormattedString(decimal)` exists in `Extensions.cs` and again in `FileService.cs`.
- **Culture setup in `Program.cs`** runs *after* `MapRazorComponents` and just sets `DefaultThreadCurrentCulture` globally. Should be `RequestLocalizationOptions` middleware.
- **Constructors doing I/O.** `AccountRepository`, `SubclassRepository`, `SplitPredefinitonRepository`, `BookingRuleRepository` call `EnsureFileExisits` in the constructor.
- **Typos leak into public API.** `EnsureFileExisits`, `SplitPredefinitonRepository`.
- **Dead / redundant code.** `AccountRepository.EnsureFileExisits` uses `new` to hide the base method; its `file.IndexOf("Accounts") > -1` guard is redundant. `Home.razor.cs:50` uses a `loaded` flag though `OnInitializedAsync` already runs once per instance. Commented-out `//var subclasses = subclassRepository.GetSubClassList();` in `ChartService`.
- **Legacy nuget packages.** All three library projects reference `System.Text.RegularExpressions 4.3.1`, `System.Xml.XmlSerializer 4.3.0`, `System.ComponentModel.Annotations 5.0.0` — legacy .NET Standard packages, redundant on `net9.0`.
- **`Common.csproj`** has `<Folder Include="Interfaces\Extensions\" />` for a folder that does not exist.
- **`ClassIds`** should be `static class`; currently instantiable.
### 3. Testability
- Zero tests, zero test project.
- Business rules (balance calculation, FX conversion, bank-transaction matching, CSV export, split logic) are entangled with mutable singletons and side effects on returned entities.
---
## Phased plan
### Phase 1 — Stop the bleeding (correctness, low churn)
1. ~~Change `ISettingsService` registration from `Singleton` to `Scoped`.~~ **Deferred.** `MyHeader.YearChanged` uses `NavigateTo(..., forceLoad: true)` after mutating settings, which tears down the SignalR circuit — a Scoped instance would be recreated with default values on the new circuit. Proper fix: persist the year/mandator selection to a cookie or query string, then Scoped becomes safe. Tracked as a new Phase 2 item.
2. Fix `TrySetYear` (capture `this.year` before overwriting). Defer the `ChartService` prior-year-loading pattern to Phase 3.
3. Fix `DummyFxService` typo (or delete the class and replace with a real `IFxService` implementation from `Schaad.Finance.Api`).
4. Remove mutation-on-read in `TransactionRepository.GetTransaction`, `ViewService.GetTransactionViewList(accountId)`, and `FileService.GetTransactionListCsv`.
5. Add a not-found guard to `TransactionRepository.GetTransaction`.
6. De-duplicate the DI registrations. One `AddAccounting()` extension called once in `Program.cs`.
7. Replace `.Class == 3/4` magic numbers with `ClassIds.*`. Update CLAUDE.md's incorrect ClassIds section.
8. Make XML writes atomic: write to `foo.xml.tmp` then `File.Move(..., overwrite: true)`. Wrap Load/Save in a per-file `SemaphoreSlim` (or a simple `lock`).
### Phase 2 — Structural cleanup
9. **Introduce a per-request unit of work / cache.** A scoped `IAccountingContext` that loads each XML file at most once per request and holds the deserialized lists.
10. Replace `Copy(target)` methods with a single merge-in-place pattern (or `record with`).
11. Extract shared formatting to a single `Formatting` helper.
12. Move `IFileService`, `IChartService`, `IViewService` out of `Common` (they depend on `Schaad.Finance.Api`). Common should have no vendor dependency.
13. Fix typos (`EnsureFileExists`, `SplitPredefinitionRepository`).
14. Move file existence bootstrapping out of constructors into a startup step (`IHostedService` or lazy first-use).
15. Fix `AccountRepository.EnsureFileExists` to compute the start-balance updates in memory and save once.
16. Remove legacy NuGet packages. Remove the stale `Interfaces\Extensions\` folder entry.
17. Set culture via `RequestLocalizationOptions` middleware.
18. Add `ILogger<T>` to services and repositories.
18b. Persist selected year and mandator across page reloads (cookie, query string, or `ProtectedLocalStorage`). Prerequisite for making `ISettingsService` `Scoped` (Phase 1 item 1, deferred).
### Phase 3 — Async & performance
19. Convert repository interfaces to async.
20. Cache the current view's data behind the scoped unit-of-work; invalidate on save.
21. Precompute `accountsById` and `subclassNameByNumber` dictionaries once per request.
22. Clean up `ChartService` prior-year loading pattern: introduce an explicit "load year data" helper instead of mutating `ISettingsService`.
### Phase 4 — Testability & safety net
23. Add a `Schaad.Accounting.Tests` xUnit project.
24. First tests: `ViewService.GetBalanceView`, `MatchBankTransactionByBookingRule` / `SameAccountsLastMonth`, `TransactionRepository` FX round-trip, `FileService.GetTransactionListCsv`.
25. Abstract the XML store (`IEntityStore<T>`) so tests use an in-memory store.
### Phase 5 — Nice-to-haves
26. Replace hand-written XML models with `record` types + source-generated serializers.
27. Inject Fixer.io key via `IOptions<FxSettings>` instead of threading through method params.
28. Prune unused Fluent UI packages.
29. Add a health/backup admin page.
---
## PR breakdown
- **PR A** — Phase 1 items 1–7 + CLAUDE.md fix.
- **PR B** — Phase 1 item 8 (atomic writes + lock).
- **PR C** — Phase 2 mechanical cleanup: items 11, 13, 15, 16, 17.
- **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 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.
- **PR G** — Phase 4 items 23 + 24: `Schaad.Accounting.Tests` xUnit + NSubstitute + Shouldly project. Adopts the `xxxTestShould.DoThisWhenThat` naming convention with Shouldly assertions (no xUnit `Assert.*`). Covers `ViewService`, `TransactionRepository`, `Formatting`, `RepositoryCache`, `AccountRepository`, `ChartService`, and `FileService.GetTransactionListCsv` — 38 tests locking in the earlier PRs' behavior. Note: originally shipped as three separate commits (initial project, expanded coverage, Shouldly + naming conversion) and later squashed into one commit at the user's request.
- **PR H** — Phase 2 item 18 (first slice): add `ILogger<FileService>` to `FileService.ImportAccountStatementFile` so bank-statement imports emit `Information` for the file being processed and each account's import count, `Warning` when an account is skipped because it belongs to a different mandator, and `Error` when the vendor parser reports a failure. Rest of the logging (BaseRepository save failures, MatchOpenBankTransactions summary) tracked as a follow-up because it requires threading loggers through all seven repositories.
- **PR I** — Phase 2 item 12: move the service interfaces (`IViewService`, `IFileService`, `IChartService`) from `Schaad.Accounting.Common` into `Schaad.Accounting.Services/Interfaces/`. Namespaces are unchanged (`Schaad.Accounting.Interfaces`), so no consumer needs a `using` update. Drops the `Schaad.Finance.Api` `<Reference>` from `Common.csproj` — Common is now vendor-free and matches its documented role as the "shared models, DTOs, interfaces" layer.
- **PR J** — Phase 2 item 18 (second slice): thread `ILogger<T>` through `BaseRepository` and every concrete repository so `BaseRepository.Save`'s catch block logs the failing file path and exception before rethrowing (previously the exception's origin was silently swallowed and only the stack trace at the callsite survived). Pulls `Microsoft.Extensions.Logging.Abstractions` into the Db project. `TransactionRepositoryTestShould` and `AccountRepositoryTestShould` use `NullLogger<T>.Instance`. Remaining item 18 work (MatchOpenBankTransactions match-count summary in `ViewService`) still open.
- **PR K** — Phase 5 item 27: introduce `IFxConverter.ConvertToChf(amount, fromCurrency)` and its `FxConverter` implementation. The vendor `IFxService` and the FixerIo API key are now hidden inside `FxConverter`; callers stop threading the API key through every method call. `ViewService` drops both `IFxService` and `ISettingsService` from its constructor and takes `IFxConverter` instead. Simplifies `GetAccountViewList` (no more per-call `settingsService.GetSettings()` reads) and `GetBalanceSheetView`. Two-test `FxConverterTestShould` locks in the target-currency and API-key routing.
- **PR L** — Phase 2 item 10: drop the hand-rolled `Copy(target)` methods from `Account`, `BookingRule`, `BookingText`, `SubClass`, and `SplitPredefinition`; keep only a `Transaction.Clone()` for defensive-copy needs in `GetTransaction` / `ViewService.WithDisplaySign`. Every `SaveXxx` now uses `FindIndex` → in-place replace (or `Add` for new entries), moving the two "hidden" defaults (`Account.Currency = "CHF"` when unset, `Transaction.BookingDate = ValueDate` when unset) into the corresponding `SaveXxx` method where they belong. Fixes a pre-existing bug: `Transaction.Copy` never copied `RelatedParty`, so update-saves silently dropped it. Added `PreserveRelatedPartyWhenRoundTrippingTransaction` and `DefaultBookingDateToValueDateWhenBookingDateIsUnset` as regression tests. 42 tests total.
- **PR M** — Phase 2 item 18 (final slice): add `ILogger<ViewService>` and log a match-count summary from `MatchOpenBankTransactions` (`"Matched {Matched} of {Total} open bank transactions"`). Completes item 18 — statement imports (PR H), repository save failures (PR J), and match runs now all emit structured logs. Also removes a dead `ISettingsService` injection from `Home.razor.cs` (declared with `[Inject]` but never used anywhere in the file).
- **PR N** — Small polish pass covering three long-noted rough edges from the original analysis: `ClassIds` becomes a `static class` (was instantiable); `Home.razor.cs` loses the redundant `loaded` guard in `OnInitializedAsync` (Blazor already runs that lifecycle hook once per component instance); the hardcoded `mandator = "Claudio Schaad"` in `SettingsService` moves to a `DefaultMandator` field on `SettingsDataset`, plumbed through `appsettings.Development.json`.
- **PR O** — Phase 5 item 26 (partial): convert `BalanceDataset` and `BalanceSheetDataset` to positional records. Both were constructor-initialised value carriers with read-only usage. The rest of item 26 was descoped: the XML-serialised domain models (`Account`, `Transaction`, ...) need mutable public setters for `XmlSerializer`, and value-equality on mutable data is a footgun (hash changes on mutation); source-generated XML serialisers do not exist without switching file formats. `DataSerie` was already a record; `MessageDataset` has a real mutation method (`Add`) and stays a class; `AccountDataset` / `TransactionDataset` / `BookingRuleDataset` inherit from the mutable domain models and can't cleanly become records without a bigger refactor.
- **PR P** — Phase 3 item 19: full async I/O top to bottom. `BaseRepository.LoadAsync/SaveAsync/LoadListAsync` return `Task<T>`; the per-file `lock (obj)` becomes `SemaphoreSlim` so it can be `await`-ed. `Save` serialises to a `MemoryStream` synchronously (XmlSerializer has no async form) then writes bytes with `File.WriteAllBytesAsync`; `File.Move` (atomic rename) has no async counterpart in .NET 9 and stays sync. Every repository interface + implementation, every service interface + implementation (except the vendor-owned `IFxService`), every Razor page/dialog `OnInitializedAsync`, and every test-file assertion becomes `async` / `await`. 51 files touched in one atomic diff — the codebase does not compile in intermediate states. `AccountRepository.EnsureAccountsFile` keeps two `.GetAwaiter().GetResult()` bridges because it runs from the constructor (constructors can't be async). The observed async payoff is one `File.ReadAllBytesAsync` and one `File.WriteAllBytesAsync` per Load/Save; after PR D each XML file is deserialised at most once per SignalR circuit, so on this single-user local Blazor Server app this is architectural-hygiene work, not a measurable perf win.

View file

@ -1,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`).

View file

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

View file

@ -1,4 +0,0 @@
namespace Schaad.Accounting.Datasets.Charts
{
public record CategoryTotal(string Name, decimal Value);
}

View file

@ -1,10 +1,23 @@
using System.Collections.Generic;
using System.Collections.Generic;
namespace Schaad.Accounting.Datasets.Reports
{
public record BalanceDataset(
List<AccountDataset> ActivaAccountList,
List<AccountDataset> PassivaAccountList,
decimal TotalActivaCHF,
decimal TotalPassivaCHF);
public class BalanceDataset
{
public List<AccountDataset> ActivaAccountList { get; set; }
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;
}
}
}

View file

@ -1,12 +1,29 @@
using System.Collections.Generic;
using System.Collections.Generic;
namespace Schaad.Accounting.Datasets.Reports
{
public record BalanceSheetDataset(
List<AccountDataset> ActivaAccountList,
List<AccountDataset> IncomeAccountList,
List<AccountDataset> ExpensesAccountList,
decimal ProfitCHF,
decimal LossCHF,
int Year);
public class BalanceSheetDataset
{
public List<AccountDataset> ActivaAccountList { get; }
public List<AccountDataset> IncomeAccountList { get; }
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;
}
}
}

View file

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

View file

@ -33,30 +33,5 @@ namespace Schaad.Accounting.Datasets
else
return Value;
}
public decimal GetValue(bool withFxRate, string accountId)
{
if (accountId == OriginAccountId)
{
return -1 * GetValue(withFxRate);
}
return GetValue(withFxRate);
}
/// <summary>
/// First word of <see cref="Text"/>, used to group related bookings (e.g. "Narbonne Car"
/// and "Narbonne Food" both group under "Narbonne").
/// </summary>
public string GetTextGroup()
{
if (string.IsNullOrEmpty(Text))
{
return Text;
}
var spaceIndex = Text.IndexOf(' ');
return spaceIndex < 0 ? Text : Text[..spaceIndex];
}
}
}

View file

@ -1,19 +0,0 @@
using System.Globalization;
namespace Schaad.Accounting
{
public static class Formatting
{
private static readonly CultureInfo SwissCulture = CreateSwissCulture();
public static string ToFormattedString(this decimal value)
=> value.ToString("#,0.00", SwissCulture);
private static CultureInfo CreateSwissCulture()
{
var culture = new CultureInfo("de-CH");
culture.NumberFormat.NumberGroupSeparator = "'";
return culture;
}
}
}

View file

@ -1,21 +1,20 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Collections.Generic;
using Schaad.Accounting.Models;
namespace Schaad.Accounting.Interfaces
{
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);
}
}

View file

@ -1,5 +1,4 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Collections.Generic;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Models;
@ -7,10 +6,10 @@ namespace Schaad.Accounting.Interfaces
{
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);
}
}

View file

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

View file

@ -1,17 +1,16 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Collections.Generic;
using Schaad.Accounting.Models;
namespace Schaad.Accounting.Interfaces
{
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);
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,12 @@
using System.Collections.Generic;
using Schaad.Accounting.Models;
namespace Schaad.Accounting.Interfaces
{
public interface ISplitPredefinitonRepository
{
List<SplitPredefinition> GetSplitPredefinitionList();
void SaveSplitPredefinition(SplitPredefinition splitPredefinition);
}
}

View file

@ -1,18 +1,17 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Collections.Generic;
using Schaad.Accounting.Models;
namespace Schaad.Accounting.Interfaces
{
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();

View file

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

View file

@ -0,0 +1,31 @@
using System.Collections.Generic;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Datasets.Reports;
using Schaad.Accounting.Models;
using Schaad.Finance.Api.Datasets;
namespace Schaad.Accounting.Interfaces
{
public interface IViewService
{
AccountDataset GetAccountView(string id);
List<AccountDataset> GetAccountViewList();
BalanceDataset GetBalanceView();
BalanceSheetDataset GetBalanceSheetView(int year);
List<TransactionDataset> GetTransactionViewList();
List<TransactionDataset> GetTransactionViewList(string accountId);
List<BookingRuleDataset> GetBookingRuleViewList();
List<BankTransaction> GetOpenBankTransactionList();
List<Transaction> MatchOpenBankTransactions();
List<Transaction> MatchCreditCardTransactions(string bankTransactionId, IReadOnlyList<CreditCardTransaction> creditCardTransactions);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,4 +4,17 @@
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>Schaad.Accounting</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageReference Include="System.Xml.XmlSerializer" Version="4.3.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<Reference Include="Schaad.Finance.Api">
<HintPath>..\dlls\Schaad.Finance.Api.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Folder Include="Interfaces\Extensions\" />
</ItemGroup>
</Project>

View file

@ -1,9 +1,7 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -13,97 +11,103 @@ namespace Schaad.Accounting.Repositories
{
private readonly string ACCOUNTS = "Accounts.xml";
public AccountRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<AccountRepository> logger)
: base(settingsService, cache, logger)
public AccountRepository(ISettingsService settingsService) : base(settingsService)
{
EnsureAccountsFile();
EnsureFileExisits(ACCOUNTS);
}
public Task<List<Account>> GetAccountListAsync() => LoadListAsync<Account>(ACCOUNTS);
public async Task SaveAccountAsync(Account account)
/// <summary>
/// Load accounts
/// </summary>
public List<Account> GetAccountList()
{
if (string.IsNullOrEmpty(account.Currency))
{
account.Currency = "CHF";
var accounts = Load<List<Account>>(ACCOUNTS);
return accounts ?? new List<Account>();
}
var accounts = await GetAccountListAsync();
var idx = accounts.FindIndex(a => a.Id == account.Id);
if (idx >= 0)
/// <summary>
/// Save an account (insert/update)
/// </summary>
public void SaveAccount(Account account)
{
accounts[idx] = account;
}
else
var accounts = GetAccountList();
var existingAccount = accounts.FirstOrDefault(a => a.Id == account.Id);
if (existingAccount == null)
{
existingAccount = new Account();
accounts.Add(existingAccount);
account.Id = Guid.NewGuid().ToString();
accounts.Add(account);
}
await SaveAsync(accounts, ACCOUNTS);
account.Copy(existingAccount);
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);
}
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);
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);
}
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;
await SaveAccountAsync(account);
SaveAccount(account);
}
private void EnsureAccountsFile()
private new void EnsureFileExisits(string file)
{
string filePath = Path.Combine(settingsService.GetDbPath(), ACCOUNTS);
if (File.Exists(filePath))
string filePath = Path.Combine(settingsService.GetDbPath(), file);
if (File.Exists(filePath) == false)
{
return;
}
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), ACCOUNTS);
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), file);
if (File.Exists(lastYearFile))
{
File.Copy(lastYearFile, filePath);
}
// 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.
// Uses the sync GetAwaiter().GetResult() bridge because this runs from the
// constructor; the file was just copied so the underlying I/O is local.
var accounts = GetAccountListAsync().GetAwaiter().GetResult();
var modified = false;
// set start balance to last bank balance
if (file.IndexOf("Accounts") > -1)
{
var accounts = GetAccountList();
foreach (var account in accounts.Where(a => a.Class == ClassIds.Activa))
{
// bank accounts
if (account.LastBankBalance > 0)
{
account.StartBalance = account.LastBankBalance;
modified = true;
SaveAccount(account);
}
// cash accounts
else if (account.Number < 1010)
{
account.StartBalance = 0;
modified = true;
SaveAccount(account);
}
}
}
if (modified)
{
SaveAsync(accounts, ACCOUNTS).GetAwaiter().GetResult();
}
}
}

View file

@ -1,7 +1,5 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -12,20 +10,26 @@ namespace Schaad.Accounting.Repositories
{
private readonly string BANK_TRANSACTIONS = "BankTransactions.xml";
public BankTransactionRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<BankTransactionRepository> logger)
: base(settingsService, cache, logger)
public BankTransactionRepository(ISettingsService settingsService) : base(settingsService)
{
}
public Task<List<BankTransaction>> GetBankTransactionListAsync() => LoadListAsync<BankTransaction>(BANK_TRANSACTIONS);
/// <summary>
/// Load bank transactions
/// </summary>
public List<BankTransaction> GetBankTransactionList()
{
var transactions = Load<List<BankTransaction>>(BANK_TRANSACTIONS);
return transactions != null ? transactions : new List<BankTransaction>();
}
/// <summary>
/// Save new bank transactions, ignore existing ones (no update)
/// </summary>
public async Task<int> SaveBankTransactionListAsync(string bankAccountNumber, List<BankTransactionDataset> bankTransactions)
public int SaveBankTransactionList(string bankAccountNumber, List<BankTransactionDataset> bankTransactions)
{
var count = 0;
var transactions = await GetBankTransactionListAsync();
var transactions = GetBankTransactionList();
foreach (var bankTrx in bankTransactions)
{
var transaction = transactions.FirstOrDefault(t => t.Id == bankTrx.Id);
@ -46,13 +50,16 @@ namespace Schaad.Accounting.Repositories
count++;
}
}
await SaveAsync(transactions, BANK_TRANSACTIONS);
Save(transactions, BANK_TRANSACTIONS);
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);
}
}

View file

@ -1,47 +1,23 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Serialization;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces;
namespace Schaad.Accounting.Repositories
{
public abstract class BaseRepository
{
// One semaphore per absolute file path so concurrent SaveAsync+SaveAsync and
// SaveAsync+LoadAsync are serialized and can't observe a half-written file.
private static readonly ConcurrentDictionary<string, SemaphoreSlim> FileLocks =
new(StringComparer.OrdinalIgnoreCase);
protected readonly ISettingsService settingsService;
protected readonly RepositoryCache cache;
protected readonly ILogger logger;
protected BaseRepository(ISettingsService settingsService, RepositoryCache cache, ILogger logger)
protected BaseRepository(ISettingsService settingsService)
{
this.settingsService = settingsService;
this.cache = cache;
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 EnsureFileExisits(string fileName)
{
string filePath = Path.Combine(settingsService.GetDbPath(), fileName);
var semaphore = GetLock(filePath);
semaphore.Wait();
try
{
if (File.Exists(filePath) == false)
{
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), fileName);
@ -51,103 +27,46 @@ namespace Schaad.Accounting.Repositories
}
}
}
finally
{
semaphore.Release();
}
}
/// <summary>
/// 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.
/// Save an object to an xml file
/// </summary>
protected Task<List<T>> LoadListAsync<T>(string fileName)
protected void Save<T>(T obj, string fileName)
{
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
return cache.GetOrLoadAsync(filePath, async () => await LoadAsync<List<T>>(fileName) ?? new List<T>());
}
/// <summary>
/// Save an object to an xml file. Writes to a .tmp sibling and then atomically
/// renames it, so a crash mid-write leaves the previous file intact. Invalidates
/// the corresponding cache entry.
/// </summary>
protected async Task SaveAsync<T>(T obj, string fileName)
{
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
var tmpPath = filePath + ".tmp";
var semaphore = GetLock(filePath);
await semaphore.WaitAsync();
try
using (var sww = new MemoryStream())
{
var settings = new XmlWriterSettings
{
Encoding = Encoding.UTF8,
Indent = true
};
try
using (var writer = XmlWriter.Create(sww, settings))
{
// XmlSerializer is synchronous — serialize to memory, then write async.
using var memory = new MemoryStream();
using (var writer = XmlWriter.Create(memory, settings))
{
var serializer = new XmlSerializer(typeof(T));
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);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to save {FilePath}", filePath);
if (File.Exists(tmpPath))
{
try { File.Delete(tmpPath); } catch { /* best effort */ }
}
throw;
var xsSubmit = new XmlSerializer(typeof(T));
xsSubmit.Serialize(writer, obj);
var xml = Encoding.UTF8.GetString(sww.ToArray());
File.WriteAllText(filePath, xml);
}
}
finally
{
semaphore.Release();
}
cache.Invalidate(filePath);
}
/// <summary>
/// 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.
/// Load an object from an xml file
/// </summary>
protected async Task<T> LoadAsync<T>(string fileName)
protected T Load<T>(string fileName)
{
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
var semaphore = GetLock(filePath);
await semaphore.WaitAsync();
try
{
if (File.Exists(filePath) == false)
{
return default(T);
}
var bytes = await File.ReadAllBytesAsync(filePath);
using var memory = new MemoryStream(bytes);
var serializer = new XmlSerializer(typeof(T));
return (T)serializer.Deserialize(memory);
}
finally
using (XmlReader reader = XmlReader.Create(filePath))
{
semaphore.Release();
var serializer = new XmlSerializer(typeof(T));
return (T)serializer.Deserialize(reader);
}
}
private static SemaphoreSlim GetLock(string filePath)
=> FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1));
}
}

View file

@ -1,8 +1,6 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -12,41 +10,55 @@ namespace Schaad.Accounting.Repositories
{
private readonly string BOOKING_RULES = "BookingRules.xml";
public BookingRuleRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<BookingRuleRepository> logger)
: base(settingsService, cache, logger)
public BookingRuleRepository(ISettingsService settingsService) : base(settingsService)
{
EnsureFileExists(BOOKING_RULES);
EnsureFileExisits(BOOKING_RULES);
}
public Task<List<BookingRule>> GetBookingRuleListAsync() => LoadListAsync<BookingRule>(BOOKING_RULES);
public async Task SaveBookingRuleAsync(BookingRule bookingRule)
/// <summary>
/// Load booking rules
/// </summary>
public List<BookingRule> GetBookingRuleList()
{
var bookingRules = await GetBookingRuleListAsync();
var idx = bookingRules.FindIndex(r => r.Id == bookingRule.Id);
if (idx >= 0)
{
bookingRules[idx] = bookingRule;
var bookingTexts = Load<List<BookingRule>>(BOOKING_RULES);
return bookingTexts ?? new List<BookingRule>();
}
else
/// <summary>
/// Save a booking rule (insert/update)
/// </summary>
public void SaveBookingRule(BookingRule bookingRule)
{
var bookingRules = GetBookingRuleList();
var existingRule = bookingRules.FirstOrDefault(a => a.Id == bookingRule.Id);
if (existingRule == null)
{
existingRule = new BookingRule();
bookingRules.Add(existingRule);
bookingRule.Id = Guid.NewGuid().ToString();
bookingRules.Add(bookingRule);
}
await SaveAsync(bookingRules, BOOKING_RULES);
bookingRule.Copy(existingRule);
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);
}
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);
await SaveAsync(bookingRules, BOOKING_RULES);
Save(bookingRules, BOOKING_RULES);
}
}
}

View file

@ -1,8 +1,6 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -12,41 +10,55 @@ namespace Schaad.Accounting.Repositories
{
private readonly string BOOKING_TEXTS = "BookingTexts.xml";
public BookingTextRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<BookingTextRepository> logger)
: base(settingsService, cache, logger)
public BookingTextRepository(ISettingsService settingsService) : base(settingsService)
{
EnsureFileExists(BOOKING_TEXTS);
EnsureFileExisits(BOOKING_TEXTS);
}
public Task<List<BookingText>> GetBookingTextListAsync() => LoadListAsync<BookingText>(BOOKING_TEXTS);
public async Task SaveBookingTextAsync(BookingText bookingText)
/// <summary>
/// Load bokking texts
/// </summary>
public List<BookingText> GetBookingTextList()
{
var bookingTexts = await GetBookingTextListAsync();
var idx = bookingTexts.FindIndex(t => t.Id == bookingText.Id);
if (idx >= 0)
{
bookingTexts[idx] = bookingText;
var bookingTexts = Load<List<BookingText>>(BOOKING_TEXTS);
return bookingTexts ?? new List<BookingText>();
}
else
/// <summary>
/// Save a booking text (insert/update)
/// </summary>
public void SaveBookingText(BookingText bookingText)
{
var bookingTexts = GetBookingTextList();
var existingText = bookingTexts.FirstOrDefault(a => a.Id == bookingText.Id);
if (existingText == null)
{
existingText = new BookingText();
bookingTexts.Add(existingText);
bookingText.Id = Guid.NewGuid().ToString();
bookingTexts.Add(bookingText);
}
await SaveAsync(bookingTexts, BOOKING_TEXTS);
bookingText.Copy(existingText);
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);
}
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);
await SaveAsync(bookingTexts, BOOKING_TEXTS);
Save(bookingTexts, BOOKING_TEXTS);
}
}
}

View file

@ -1,39 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
namespace Schaad.Accounting.Repositories
{
public class SplitPredefinitionRepository : BaseRepository, ISplitPredefinitionRepository
{
private readonly string SPLIT_PREDEFINITION = "SplitPredefinitions.xml";
public SplitPredefinitionRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<SplitPredefinitionRepository> logger)
: base(settingsService, cache, logger)
{
EnsureFileExists(SPLIT_PREDEFINITION);
}
public Task<List<SplitPredefinition>> GetSplitPredefinitionListAsync() => LoadListAsync<SplitPredefinition>(SPLIT_PREDEFINITION);
public async Task SaveSplitPredefinitionAsync(SplitPredefinition splitPredefinition)
{
var definitions = await GetSplitPredefinitionListAsync();
var idx = definitions.FindIndex(d => d.Id == splitPredefinition.Id);
if (idx >= 0)
{
definitions[idx] = splitPredefinition;
}
else
{
splitPredefinition.Id = Guid.NewGuid().ToString();
definitions.Add(splitPredefinition);
}
await SaveAsync(definitions, SPLIT_PREDEFINITION);
}
}
}

View file

@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
namespace Schaad.Accounting.Repositories
{
public class SplitPredefinitonRepository : BaseRepository, ISplitPredefinitonRepository
{
private readonly string SPLIT_PREDEFINITION = "SplitPredefinitions.xml";
public SplitPredefinitonRepository(ISettingsService settingsService) : base(settingsService)
{
EnsureFileExisits(SPLIT_PREDEFINITION);
}
/// <summary>
/// Load booking rules
/// </summary>
public List<SplitPredefinition> GetSplitPredefinitionList()
{
var definitions = Load<List<SplitPredefinition>>(SPLIT_PREDEFINITION);
return definitions ?? new List<SplitPredefinition>();
}
/// <summary>
/// Save a booking rule (insert/update)
/// </summary>
public void SaveSplitPredefinition(SplitPredefinition splitPredefinition)
{
var definitions = GetSplitPredefinitionList();
var existingDefinition = definitions.FirstOrDefault(a => a.Id == splitPredefinition.Id);
if (existingDefinition == null)
{
existingDefinition = new SplitPredefinition();
definitions.Add(existingDefinition);
splitPredefinition.Id = Guid.NewGuid().ToString();
}
splitPredefinition.Copy(existingDefinition);
Save(definitions, SPLIT_PREDEFINITION);
}
}
}

View file

@ -1,8 +1,6 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -13,10 +11,9 @@ namespace Schaad.Accounting.Repositories
private readonly Dictionary<int, string> classes = new Dictionary<int, string>();
private readonly string SUBCLASSES = "SubClasses.xml";
public SubclassRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<SubclassRepository> logger)
: base(settingsService, cache, logger)
public SubclassRepository(ISettingsService settingsService) : base(settingsService)
{
EnsureFileExists(SUBCLASSES);
EnsureFileExisits(SUBCLASSES);
classes.Add(ClassIds.Activa, "Aktiven");
classes.Add(ClassIds.Passiva, "Passiven");
@ -24,35 +21,47 @@ namespace Schaad.Accounting.Repositories
classes.Add(ClassIds.Expenses, "Ausgaben");
}
public Task<List<SubClass>> GetSubClassListAsync() => LoadListAsync<SubClass>(SUBCLASSES);
public async Task SaveSubClassAsync(SubClass subClass)
/// <summary>
/// Load bokking texts
/// </summary>
public List<SubClass> GetSubClassList()
{
var subclasses = await GetSubClassListAsync();
var idx = subclasses.FindIndex(s => s.Id == subClass.Id);
if (idx >= 0)
{
subclasses[idx] = subClass;
var subclasses = Load<List<SubClass>>(SUBCLASSES);
return subclasses ?? new List<SubClass>();
}
else
/// <summary>
/// Save a booking text (insert/update)
/// </summary>
public void SaveSubClass(SubClass subClass)
{
var subclasses = GetSubClassList();
var existingSubClass = subclasses.FirstOrDefault(a => a.Id == subClass.Id);
if (existingSubClass == null)
{
existingSubClass = new SubClass();
subclasses.Add(existingSubClass);
subClass.Id = Guid.NewGuid().ToString();
subclasses.Add(subClass);
}
await SaveAsync(subclasses, SUBCLASSES);
subClass.Copy(existingSubClass);
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);
}
public async Task DeleteSubClassAsync(string id)
public void DeleteSubClass(string id)
{
var subclasses = await GetSubClassListAsync();
var subclasses = GetSubClassList();
subclasses.RemoveAll(t => t.Id == id);
await SaveAsync(subclasses, SUBCLASSES);
Save(subclasses, SUBCLASSES);
}
public Dictionary<int, string> GetClasses()

View file

@ -1,8 +1,6 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -13,79 +11,82 @@ namespace Schaad.Accounting.Repositories
private readonly IAccountRepository accountRepository;
private readonly string TRANSACTIONS = "Transactions.xml";
public TransactionRepository(ISettingsService settingsService, RepositoryCache cache, IAccountRepository accountRepository, ILogger<TransactionRepository> logger)
: base(settingsService, cache, logger)
public TransactionRepository(ISettingsService settingsService, IAccountRepository accountRepository) : base(settingsService)
{
this.accountRepository = accountRepository;
}
public Task<List<Transaction>> GetTransactionListAsync() => LoadListAsync<Transaction>(TRANSACTIONS);
/// <summary>
/// Get transaction list
/// </summary>
public List<Transaction> GetTransactionList()
{
var transactionList = Load<List<Transaction>>(TRANSACTIONS);
return transactionList ?? new List<Transaction>();
}
public async Task SaveTransactionAsync(Transaction transaction)
/// <summary>
/// Save a transaction (insert/update)
/// </summary>
public void SaveTransaction(Transaction transaction)
{
transaction.Value = Math.Abs(transaction.Value);
var originIsFx = (await accountRepository.GetAccountAsync(transaction.OriginAccountId)).IsFxAccount;
var targetIsFx = (await accountRepository.GetAccountAsync(transaction.TargetAccountId)).IsFxAccount;
var isFxAccount = originIsFx || targetIsFx;
// prevent saving fxrate for chf account
var isFxAccount = accountRepository.GetAccount(transaction.OriginAccountId).IsFxAccount;
// user enters amount in the foreign account's currency -> convert to CHF for storage
// if source account is fx account, we pass a foreign value -> calc to chf
if (isFxAccount && transaction.FxRate != 0)
{
transaction.Value = transaction.Value * transaction.FxRate.Value;
}
if (!isFxAccount)
isFxAccount |= accountRepository.GetAccount(transaction.TargetAccountId).IsFxAccount;
if (isFxAccount == false)
{
transaction.FxRate = null;
}
if (transaction.BookingDate <= DateTime.MinValue)
{
transaction.BookingDate = transaction.ValueDate;
}
var transactionList = await GetTransactionListAsync();
var idx = transactionList.FindIndex(t => t.Id == transaction.Id);
if (idx >= 0)
{
transactionList[idx] = transaction;
}
else
var transactionList = GetTransactionList();
var existingTransaction = transactionList.FirstOrDefault(a => a.Id == transaction.Id);
if (existingTransaction == null)
{
existingTransaction = new Transaction();
transactionList.Add(existingTransaction);
transaction.Id = Guid.NewGuid().ToString();
transactionList.Add(transaction);
}
await SaveAsync(transactionList, TRANSACTIONS);
transaction.Copy(existingTransaction);
Save(transactionList, TRANSACTIONS);
}
public async Task<Transaction> GetTransactionAsync(string id)
/// <summary>
/// Get transaction
/// </summary>
public Transaction GetTransaction(string id)
{
var transactions = await GetTransactionListAsync();
var stored = transactions.FirstOrDefault(t => t.Id == id);
if (stored == null)
var transactions = GetTransactionList();
var transaction = transactions.FirstOrDefault(t => t.Id == id);
// if source account is fx account, we save in chf currency -> calc to foreign currency for display
var isFxAccount = accountRepository.GetAccount(transaction.OriginAccountId).IsFxAccount;
if (isFxAccount && transaction.FxRate != 0)
{
return null;
transaction.Value = transaction.Value / transaction.FxRate.Value;
}
var result = stored.Clone();
// value is stored in CHF -> convert back to foreign currency for display/editing
var isFxAccount = (await accountRepository.GetAccountAsync(result.OriginAccountId)).IsFxAccount
|| (await accountRepository.GetAccountAsync(result.TargetAccountId)).IsFxAccount;
if (isFxAccount && result.FxRate != 0)
{
result.Value = result.Value / result.FxRate.Value;
return transaction;
}
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);
await SaveAsync(transactions, TRANSACTIONS);
Save(transactions, TRANSACTIONS);
}
}
}

View file

@ -1,39 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Schaad.Accounting.Repositories
{
/// <summary>
/// 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
/// then loaded at most once per circuit. Writes go through <see cref="BaseRepository.SaveAsync"/>,
/// which invalidates the corresponding entry so the next read reloads from disk.
///
/// Not thread-safe — callers within a single Blazor Server circuit run serialized on the
/// circuit's synchronization context, so no locking is needed.
/// </summary>
public class RepositoryCache
{
private readonly Dictionary<string, object> entries =
new(StringComparer.OrdinalIgnoreCase);
public async Task<List<T>> GetOrLoadAsync<T>(string cacheKey, Func<Task<List<T>>> loader)
{
if (entries.TryGetValue(cacheKey, out var cached))
{
return (List<T>)cached;
}
var list = await loader();
entries[cacheKey] = list;
return list;
}
public void Invalidate(string cacheKey)
{
entries.Remove(cacheKey);
}
}
}

View file

@ -4,11 +4,10 @@
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
</ItemGroup>
</Project>

View file

@ -1,8 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Datasets.Charts;
using Schaad.Accounting.Interfaces;
@ -10,135 +8,115 @@ namespace Schaad.Accounting.Services
{
public class ChartService : IChartService
{
private readonly IAccountRepository accountRepository;
private readonly ISettingsService settingsService;
private readonly ISubclassRepository subclassRepository;
private readonly IViewService viewService;
public ChartService(ISettingsService settingsService, IViewService viewService)
public ChartService(
ISettingsService settingsService,
IViewService viewService,
IAccountRepository accountRepository,
ISubclassRepository subclassRepository)
{
this.settingsService = settingsService;
this.viewService = viewService;
this.accountRepository = accountRepository;
this.subclassRepository = subclassRepository;
}
public async Task<IReadOnlyList<DataSerie>> GetExpensesPerMonthAsync()
public IReadOnlyList<DataSerie> GetExpensesPerMonth()
{
var expenseTransactions = (await viewService.GetTransactionViewListAsync())
.Where(t => t.TargetAccount.Class == ClassIds.Expenses)
.ToList();
var transactions = viewService.GetTransactionViewList().Where(a => a.TargetAccount.Class == ClassIds.Expenses);
var distinctSubClasses = expenseTransactions
.Select(t => t.TargetAccount.SubClass)
.Distinct()
.Count();
return distinctSubClasses > 1
? await GetSubClassExpensesPerMonthAsync(expenseTransactions)
: await GetAccountExpensesPerMonthAsync();
// More than one subclass xx
if (transactions.Select(t => t.TargetAccount.SubClass).Distinct().Count() > 1)
{
return GetSubClassExpensesPerMonth();
}
private async Task<List<DataSerie>> GetSubClassExpensesPerMonthAsync(List<TransactionDataset> expenseTransactions)
// only one subclass xx (z.B. Mandant Mannenbach)
return GetAccountExpensesPerMonth();
}
private List<DataSerie> GetSubClassExpensesPerMonth()
{
var year = settingsService.GetYear();
var maxMonth = expenseTransactions
.OrderByDescending(t => t.ValueDate)
.FirstOrDefault()?.ValueDate.Month ?? 12;
var subClassNameByNumber = (await viewService.GetAccountViewListAsync())
.GroupBy(a => a.SubClass)
.ToDictionary(g => g.Key, g => g.First().SubClassName);
//var subclasses = subclassRepository.GetSubClassList();
var transactions = viewService.GetTransactionViewList().Where(a => a.TargetAccount.Class == ClassIds.Expenses).ToList();
var list = new List<DataSerie>();
foreach (var grp in expenseTransactions.GroupBy(t => t.TargetAccount.SubClass))
var newestTransaction = transactions.OrderByDescending(t => t.ValueDate).FirstOrDefault();
var maxMonth = newestTransaction?.ValueDate.Month ?? 12;
var year = settingsService.GetYear();
// Group by subclass
foreach (var grp in transactions.GroupBy(a => a.TargetAccount.SubClass).Select(a => new {Key = a.Key, List = a.ToList()}))
{
var groupedByMonth = grp
.GroupBy(t => t.ValueDate.Month)
.ToDictionary(g => g.Key, g => g.Sum(t => t.GetValue(t.TargetAccount.IsFxAccount)));
// Sum subclass transactions per month
var groupedByMonth = grp.List.GroupBy(g => g.ValueDate.Month).ToDictionary(g => g.Key, g => g.ToList().Sum(s => s.Value));
EnsureEntryForEveryMonth(groupedByMonth, maxMonth);
list.Add(new DataSerie(
Id: grp.Key.ToString(),
Name: subClassNameByNumber[grp.Key],
//var subClass = subclasses.FirstOrDefault(s => s.Number == grp.List.First().TargetAccount.SubClass);
list.Add(
new DataSerie(
Id: grp.List.First().TargetAccount.SubClass.ToString(),
Name: grp.List.First().TargetAccount.Name,
X: groupedByMonth.OrderBy(g => g.Key).Select(g => new DateOnly(year, g.Key, 1)).ToList(),
Y: groupedByMonth.OrderBy(g => g.Key).Select(g => g.Value).ToList()
));
)
);
}
return list;
}
private async Task<List<DataSerie>> GetAccountExpensesPerMonthAsync()
private List<DataSerie> GetAccountExpensesPerMonth()
{
var year = settingsService.GetYear();
var allTransactions = await viewService.GetTransactionViewListAsync();
var expenseAccounts = (await viewService.GetAccountViewListAsync()).Where(a => a.Class == ClassIds.Expenses);
var list = new List<DataSerie>();
foreach (var account in expenseAccounts)
var expensesAccounts = viewService.GetAccountViewList().Where(a => a.Class == ClassIds.Expenses);
foreach (var account in expensesAccounts)
{
var transactions = allTransactions.Where(t => t.TargetAccountId == account.Id).ToList();
if (transactions.Count == 0)
var serie = GetAccountExpensesPerMonth(account.Id, DateTime.Now.Year);
if (serie != null)
{
continue;
list.Add(serie);
}
}
return list;
}
var maxMonth = transactions.Max(t => t.ValueDate.Month);
var groupedByMonth = transactions
.GroupBy(t => t.ValueDate.Month)
.ToDictionary(g => g.Key, g => g.Sum(t => t.GetValue(account.IsFxAccount)));
private DataSerie GetAccountExpensesPerMonth(string accountId, int year)
{
if (settingsService.TrySetYear(year))
{
var accountList = accountRepository.GetAccountList();
var account = accountList.SingleOrDefault(a => a.Id == accountId);
// perhaps we dont have the account for last year
if (account == null)
{
return null;
}
var transactions = viewService.GetTransactionViewList().Where(t => t.TargetAccountId == accountId).ToList();
var newestTransaction = transactions.OrderByDescending(t => t.ValueDate).FirstOrDefault();
var maxMonth = newestTransaction?.ValueDate.Month ?? 12;
var groupedByMonth = transactions.GroupBy(g => g.ValueDate.Month).ToDictionary(g => g.Key, g => g.ToList().Sum(s => s.Value));
EnsureEntryForEveryMonth(groupedByMonth, maxMonth);
list.Add(new DataSerie(
Id: account.Id,
settingsService.SetYear(DateTime.Now.Year);
return new DataSerie(
Id: accountId,
Name: account.Name,
X: groupedByMonth.OrderBy(g => g.Key).Select(g => new DateOnly(year, g.Key, 1)).ToList(),
Y: groupedByMonth.OrderBy(g => g.Key).Select(g => g.Value).ToList()
));
}
return list;
);
}
public async Task<IReadOnlyList<DataSerie>> GetBookingTextExpensesPerMonthAsync(string accountId)
{
var account = await viewService.GetAccountViewAsync(accountId);
var transactions = await viewService.GetTransactionViewListAsync(accountId);
if (transactions.Count == 0)
{
return new List<DataSerie>();
return null;
}
var year = settingsService.GetYear();
var maxMonth = transactions.Max(t => t.ValueDate.Month);
var list = new List<DataSerie>();
foreach (var grp in transactions.GroupBy(t => t.GetTextGroup()))
{
var groupedByMonth = grp
.GroupBy(t => t.ValueDate.Month)
.ToDictionary(g => g.Key, g => g.Sum(t => t.GetValue(account.IsFxAccount, accountId)));
EnsureEntryForEveryMonth(groupedByMonth, maxMonth);
list.Add(new DataSerie(
Id: grp.Key,
Name: grp.Key,
X: groupedByMonth.OrderBy(g => g.Key).Select(g => new DateOnly(year, g.Key, 1)).ToList(),
Y: groupedByMonth.OrderBy(g => g.Key).Select(g => g.Value).ToList()
));
}
return list;
}
public async Task<IReadOnlyList<CategoryTotal>> GetBookingTextTotalsAsync(string accountId)
{
var account = await viewService.GetAccountViewAsync(accountId);
var transactions = await viewService.GetTransactionViewListAsync(accountId);
return transactions
.Where(t => t.TargetAccountId == accountId)
.GroupBy(t => t.GetTextGroup())
.Select(g => new CategoryTotal(g.Key, g.Sum(t => t.GetValue(account.IsFxAccount, accountId))))
.Where(c => c.Value != 0)
.ToList();
}
private static void EnsureEntryForEveryMonth(Dictionary<int, decimal> values, int maxMonth = 12)
private void EnsureEntryForEveryMonth(Dictionary<int, decimal> values, int maxMonth = 12)
{
for (int i = 1; i <= maxMonth; i++)
{

View file

@ -10,7 +10,7 @@ namespace Schaad.Accounting.Services
public decimal ConvertCurrency(decimal amount, string fromCurrency, string toCurrency, string fixerIoApiKey)
{
if (currencies.Contains(fromCurrency) && currencies.Contains(toCurrency))
if (currencies.Contains(fromCurrency) && currencies.Contains(fromCurrency))
{
return amount;
}

View file

@ -1,9 +1,8 @@
using System.Collections.Generic;
using System.Globalization;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces;
using Schaad.Finance.Api;
@ -19,7 +18,6 @@ namespace Schaad.Accounting.Services
private readonly ISettingsService settingsService;
private readonly IAccountStatementService accountStatementService;
private readonly ICreditCardStatementService creditCardStatementService;
private readonly ILogger<FileService> logger;
public FileService(
ISettingsService settingsService,
@ -27,8 +25,7 @@ namespace Schaad.Accounting.Services
ITransactionRepository transactionsRepository,
IBankTransactionRepository bankTransactionRepository,
IAccountStatementService accountStatementService,
ICreditCardStatementService creditCardStatementService,
ILogger<FileService> logger)
ICreditCardStatementService creditCardStatementService)
{
this.settingsService = settingsService;
this.accountRepository = accountRepository;
@ -36,7 +33,6 @@ namespace Schaad.Accounting.Services
this.bankTransactionRepository = bankTransactionRepository;
this.accountStatementService = accountStatementService;
this.creditCardStatementService = creditCardStatementService;
this.logger = logger;
}
public string Backup()
@ -48,21 +44,19 @@ namespace Schaad.Accounting.Services
}
// 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);
var messages = new List<MessageDataset>();
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)
{
var account = accountStatementResult.AccountStatement;
if (accountStatementResult.IsSuccess == false)
{
logger.LogError("Statement parse failed for account {AccountNumber}: {Error}", account.AccountNumber, accountStatementResult.Error);
messages.Add(new MessageDataset($"Account {account.AccountNumber} NICHT importiert: {accountStatementResult.Error}", MessageStatus.Error));
}
@ -91,11 +85,8 @@ namespace Schaad.Accounting.Services
Creditor = t.Creditor
})
.ToList();
var count = await bankTransactionRepository.SaveBankTransactionListAsync(account.AccountNumber, transactionsThisYear);
await accountRepository.SaveBankAccountBalanceAsync(account.AccountNumber, (decimal)account.EndBalance.Value);
logger.LogInformation("Imported {Imported} of {Total} transactions for account {AccountNumber}",
count, account.Transactions.Count, account.AccountNumber);
var count = bankTransactionRepository.SaveBankTransactionList(account.AccountNumber, transactionsThisYear);
accountRepository.SaveBankAccountBalance(account.AccountNumber, (decimal)account.EndBalance.Value);
var status = count == account.Transactions.Count ? MessageStatus.Success : MessageStatus.Info;
message.Add($"{count} von {account.Transactions.Count} Transaktion(en) importiert.", status);
@ -109,8 +100,6 @@ namespace Schaad.Accounting.Services
}
else
{
logger.LogWarning("Skipping account {AccountNumber}: not part of the currently selected mandator ({Skipped} transactions ignored)",
account.AccountNumber, account.Transactions.Count);
message.Add($"Falscher Mandant: {account.Transactions.Count} Transaktion(en) nicht importiert.", MessageStatus.Warning);
}
}
@ -124,11 +113,11 @@ namespace Schaad.Accounting.Services
return creditCardTransactions;
}
public async Task<byte[]> GetTransactionListCsvAsync(string accountId)
public byte[] GetTransactionListCsv(string accountId)
{
var sb = new StringBuilder();
var account = await accountRepository.GetAccountAsync(accountId);
var transactions = (await transactionsRepository.GetTransactionListAsync())
var account = accountRepository.GetAccount(accountId);
var transactions = transactionsRepository.GetTransactionList()
.Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId)
.OrderBy(t => t.BookingDate)
.ThenBy(t => t.ValueDate)
@ -141,22 +130,28 @@ namespace Schaad.Accounting.Services
{
var credit = "";
var debit = "";
var signedValue = trx.Value;
if (trx.OriginAccountId == accountId)
{
debit = trx.Value.ToFormattedString();
signedValue = -trx.Value;
debit = ToFormattedString(trx.Value);
trx.Value *= -1;
}
else
{
credit = trx.Value.ToFormattedString();
credit = ToFormattedString(trx.Value);
}
balance += signedValue;
sb.AppendLine($"{trx.BookingDate:dd.MM.yyyy};{trx.ValueDate:dd.MM.yyyy};{trx.Text};{debit};{credit};{balance.ToFormattedString()}");
balance += trx.Value;
sb.AppendLine($"{trx.BookingDate:dd.MM.yyyy};{trx.ValueDate:dd.MM.yyyy};{trx.Text};{debit};{credit};{ToFormattedString(balance)}");
}
var fileBytes = Encoding.GetEncoding("ISO-8859-1").GetBytes(sb.ToString());
return fileBytes;
}
private string ToFormattedString(decimal value)
{
var culture = new CultureInfo("de-CH");
culture.NumberFormat.NumberGroupSeparator = "'";
return value.ToString("#,0.00", culture);
}
}
}

View file

@ -1,21 +0,0 @@
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces;
using Schaad.Finance.Api;
namespace Schaad.Accounting.Services
{
public class FxConverter : IFxConverter
{
private readonly IFxService fxService;
private readonly SettingsDataset settings;
public FxConverter(IFxService fxService, SettingsDataset settings)
{
this.fxService = fxService;
this.settings = settings;
}
public decimal ConvertToChf(decimal amount, string fromCurrency)
=> fxService.ConvertCurrency(amount, fromCurrency, "CHF", settings.FixerIoApiKey);
}
}

View file

@ -1,15 +0,0 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Datasets.Charts;
namespace Schaad.Accounting.Interfaces
{
public interface IChartService
{
Task<IReadOnlyList<DataSerie>> GetExpensesPerMonthAsync();
Task<IReadOnlyList<DataSerie>> GetBookingTextExpensesPerMonthAsync(string accountId);
Task<IReadOnlyList<CategoryTotal>> GetBookingTextTotalsAsync(string accountId);
}
}

View file

@ -1,11 +0,0 @@
namespace Schaad.Accounting.Interfaces
{
/// <summary>
/// Thin wrapper over the vendor <c>IFxService</c> that hides the FixerIo API key
/// and the base currency, so callers just ask "convert this amount to CHF".
/// </summary>
public interface IFxConverter
{
decimal ConvertToChf(decimal amount, string fromCurrency);
}
}

View file

@ -1,32 +0,0 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Datasets.Reports;
using Schaad.Accounting.Models;
using Schaad.Finance.Api.Datasets;
namespace Schaad.Accounting.Interfaces
{
public interface IViewService
{
Task<AccountDataset> GetAccountViewAsync(string id);
Task<List<AccountDataset>> GetAccountViewListAsync();
Task<BalanceDataset> GetBalanceViewAsync();
Task<BalanceSheetDataset> GetBalanceSheetViewAsync(int year);
Task<List<TransactionDataset>> GetTransactionViewListAsync();
Task<List<TransactionDataset>> GetTransactionViewListAsync(string accountId);
Task<List<BookingRuleDataset>> GetBookingRuleViewListAsync();
Task<List<BankTransaction>> GetOpenBankTransactionListAsync();
Task<List<Transaction>> MatchOpenBankTransactionsAsync();
Task<List<Transaction>> MatchCreditCardTransactionsAsync(string bankTransactionId, IReadOnlyList<CreditCardTransaction> creditCardTransactions);
}
}

View file

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

View file

@ -10,13 +10,12 @@ namespace Schaad.Accounting.Services
public class SettingsService : ISettingsService
{
private readonly SettingsDataset settingsDataset;
private string mandator;
private string mandator = "Claudio Schaad";
private int year = DateTime.Now.Year;
public SettingsService(SettingsDataset settingsDataset)
{
this.settingsDataset = settingsDataset;
this.mandator = settingsDataset.DefaultMandator;
}
public int GetYear()
@ -31,7 +30,7 @@ namespace Schaad.Accounting.Services
public bool TrySetYear(int year)
{
var oldYear = this.year;
var oldYear = year;
SetYear(year);
if (Directory.GetFiles(GetDbPath()).Any() == false)
{

View file

@ -1,12 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Datasets.Reports;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Finance.Api;
using Schaad.Finance.Api.Datasets;
namespace Schaad.Accounting.Services
@ -18,8 +17,8 @@ namespace Schaad.Accounting.Services
private readonly IBookingRuleRepository bookingRuleRepository;
private readonly ISubclassRepository subclassRepository;
private readonly ITransactionRepository transactionRepository;
private readonly IFxConverter fxConverter;
private readonly ILogger<ViewService> logger;
private readonly IFxService fxService;
private readonly ISettingsService settingsService;
public ViewService(
IAccountRepository accountRepository,
@ -27,165 +26,200 @@ namespace Schaad.Accounting.Services
ITransactionRepository transactionRepository,
ISubclassRepository subclassRepository,
IBookingRuleRepository bookingRuleRepository,
IFxConverter fxConverter,
ILogger<ViewService> logger)
IFxService fxService,
ISettingsService settingsService)
{
this.accountRepository = accountRepository;
this.bankTransactionRepository = bankTransactionRepository;
this.transactionRepository = transactionRepository;
this.subclassRepository = subclassRepository;
this.bookingRuleRepository = bookingRuleRepository;
this.fxConverter = fxConverter;
this.logger = logger;
this.fxService = fxService;
this.settingsService = settingsService;
}
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);
}
public async Task<List<AccountDataset>> GetAccountViewListAsync()
/// <summary>
/// Load accounts and calculates their balances
/// </summary>
public List<AccountDataset> GetAccountViewList()
{
var accounts = await accountRepository.GetAccountListAsync();
var transactionList = await GetTransactionViewListAsync();
var subClassNameByNumber = (await subclassRepository.GetSubClassListAsync()).ToDictionary(s => s.Number, s => s.Name);
var accounts = accountRepository.GetAccountList();
var transactionList = GetTransactionViewList();
var subClassNameByNumber = subclassRepository.GetSubClassList().ToDictionary(s => s.Number, s => s.Name);
var settings = settingsService.GetSettings();
// Group transactions by account so per-account balance is O(k) instead of O(M).
var creditsByAccount = transactionList.GroupBy(t => t.TargetAccountId).ToDictionary(g => g.Key, g => g.ToList());
var debitsByAccount = transactionList.GroupBy(t => t.OriginAccountId).ToDictionary(g => g.Key, g => g.ToList());
var accountList = accounts.Select(a =>
{
var balance = ComputeBalance(a, creditsByAccount, debitsByAccount);
return new AccountDataset(
var accountList = accounts.Select(
a =>
new AccountDataset(
account: a,
balance: balance,
balanceCHF: fxConverter.ConvertToChf(balance, a.Currency),
startBalanceCHF: fxConverter.ConvertToChf(a.StartBalance, a.Currency),
balance: GetBalanceInAccountCurrency(a, transactionList),
balanceCHF: GetCurrentBalanceInCHF(a, transactionList),
startBalanceCHF: fxService.ConvertCurrency(a.StartBalance, a.Currency, "CHF", settings.FixerIoApiKey),
className: subclassRepository.GetClass(a.Class),
subClassName: subClassNameByNumber[a.SubClass]);
})
subClassName: subClassNameByNumber[a.SubClass]
)
)
.ToList();
return accountList.OrderBy(a => a.Number).ToList();
}
private static decimal ComputeBalance(
Account account,
Dictionary<string, List<TransactionDataset>> creditsByAccount,
Dictionary<string, List<TransactionDataset>> debitsByAccount)
public BalanceDataset GetBalanceView()
{
var balance = account.StartBalance;
if (creditsByAccount.TryGetValue(account.Id, out var credits))
{
balance += credits.Sum(t => t.GetValue(account.IsFxAccount));
var accountList = GetAccountViewList();
var balanceView = new BalanceDataset(
activaAccountList: accountList.Where(m => m.Class == ClassIds.Activa).ToList(),
passivaAccountList: accountList.Where(m => m.Class == ClassIds.Passiva).ToList(),
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))
);
return balanceView;
}
if (debitsByAccount.TryGetValue(account.Id, out var debits))
public BalanceSheetDataset GetBalanceSheetView(int year)
{
balance -= debits.Sum(t => t.GetValue(account.IsFxAccount));
var accountList = GetAccountViewList();
var settings = settingsService.GetSettings();
var profit = Math.Abs(accountList.Where(m => m.Class == ClassIds.Income).Sum(m =>m.BalanceCHF));
profit += Math.Abs(accountList.Where(m => m.Class == ClassIds.Activa).Sum(m => fxService.ConvertCurrency(m.StartBalance, m.Currency, "CHF", settings.FixerIoApiKey)));
var loss = Math.Abs(accountList.Where(m => m.Class == ClassIds.Expenses).Sum(m => m.BalanceCHF));
var balanceView = new BalanceSheetDataset(
activaAccountList: accountList.Where(m => m.Class == ClassIds.Activa).ToList(),
incomeAccountList: accountList.Where(m => m.Class == ClassIds.Income).ToList(),
expensesAccountList: accountList.Where(m => m.Class == ClassIds.Expenses).ToList(),
profitCHF: profit,
lossCHF: loss,
year: year
);
return balanceView;
}
private decimal GetBalanceInAccountCurrency(Account a, List<TransactionDataset> transactionList)
{
var balance = a.StartBalance
+ transactionList.Where(t => t.TargetAccountId == a.Id).Sum(t => t.GetValue(a.IsFxAccount))
- transactionList.Where(t => t.OriginAccountId == a.Id).Sum(t => t.GetValue(a.IsFxAccount));
return balance;
}
public async Task<BalanceDataset> GetBalanceViewAsync()
private decimal GetCurrentBalanceInCHF(Account a, List<TransactionDataset> transactionList)
{
var accountList = await GetAccountViewListAsync();
return new BalanceDataset(
ActivaAccountList: accountList.Where(m => m.Class == ClassIds.Activa).ToList(),
PassivaAccountList: accountList.Where(m => m.Class == ClassIds.Passiva).ToList(),
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)));
}
public async Task<BalanceSheetDataset> GetBalanceSheetViewAsync(int year)
{
var accountList = await GetAccountViewListAsync();
var profit = Math.Abs(accountList.Where(m => m.Class == ClassIds.Income).Sum(m => m.BalanceCHF));
profit += Math.Abs(accountList.Where(m => m.Class == ClassIds.Activa).Sum(m => fxConverter.ConvertToChf(m.StartBalance, m.Currency)));
var loss = Math.Abs(accountList.Where(m => m.Class == ClassIds.Expenses).Sum(m => m.BalanceCHF));
return new BalanceSheetDataset(
ActivaAccountList: accountList.Where(m => m.Class == ClassIds.Activa).ToList(),
IncomeAccountList: accountList.Where(m => m.Class == ClassIds.Income).ToList(),
ExpensesAccountList: accountList.Where(m => m.Class == ClassIds.Expenses).ToList(),
ProfitCHF: profit,
LossCHF: loss,
Year: year);
var settings = settingsService.GetSettings();
var balanceInAccountCurrency = GetBalanceInAccountCurrency(a, transactionList);
var balanceInChf = fxService.ConvertCurrency(balanceInAccountCurrency, a.Currency, "CHF", settings.FixerIoApiKey);
return balanceInChf;
}
/// <summary>
/// Get transaction list with the origin and target account for each transaction
/// </summary>
public async Task<List<TransactionDataset>> GetTransactionViewListAsync()
public List<TransactionDataset> GetTransactionViewList()
{
var accountsById = (await accountRepository.GetAccountListAsync()).ToDictionary(a => a.Id);
var transactionList = await transactionRepository.GetTransactionListAsync();
var accountList = accountRepository.GetAccountList();
var transactionList = transactionRepository.GetTransactionList();
return transactionList.Select(t =>
var transactionViewList = transactionList.Select(
t =>
new TransactionDataset(
t,
accountsById[t.OriginAccountId],
accountsById[t.TargetAccountId]))
accountList.Single(a => a.Id == t.OriginAccountId),
accountList.Single(a => a.Id == t.TargetAccountId))
)
.ToList();
return transactionViewList;
}
/// <summary>
/// Get transaction list filtered to a single account. Sign is not baked in here -
/// callers use <see cref="TransactionDataset.GetValue(bool, string)"/> with this
/// account's id to get a value that's negative when the account is paying out.
/// Get transaction list with the origin and target account for each transaction
/// </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 transactionList = (await transactionRepository.GetTransactionListAsync())
.Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId);
var accountList = accountRepository.GetAccountList();
var transactionList = transactionRepository.GetTransactionList().Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId);
return transactionList.Select(t =>
var transactionViewList = transactionList.Select(
t =>
new TransactionDataset(
t,
accountsById[t.OriginAccountId],
accountsById[t.TargetAccountId]))
Prepare(t),
accountList.Single(a => a.Id == t.OriginAccountId),
accountList.Single(a => a.Id == t.TargetAccountId)
)
)
.ToList();
return transactionViewList;
Transaction Prepare(Transaction t)
{
var account = accountList.Single(a => a.Id == t.OriginAccountId);
if (account.Class == ClassIds.Activa && accountId == t.OriginAccountId )
{
t.Value *= -1;
}
return t;
}
}
/// <summary>
/// Get booking rules with their account
/// </summary>
public async Task<List<BookingRuleDataset>> GetBookingRuleViewListAsync()
public List<BookingRuleDataset> GetBookingRuleViewList()
{
var accountsById = (await accountRepository.GetAccountListAsync()).ToDictionary(a => a.Id);
var bookingRules = await bookingRuleRepository.GetBookingRuleListAsync();
var accountList = accountRepository.GetAccountList();
var bookinRuleList = bookingRuleRepository.GetBookingRuleList();
return bookingRules.Select(t =>
new BookingRuleDataset(t, accountsById[t.AccountId].Name))
return bookinRuleList.Select(
t =>
new BookingRuleDataset(
t,
accountList.Single(a => a.Id == t.AccountId).Name
)
)
.ToList();
}
/// <summary>
/// Load open bank transactions
/// </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)
.Select(t => t.BankTransactionId);
var bankTransactions = (await bankTransactionRepository.GetBankTransactionListAsync())
var bankTransactions = bankTransactionRepository
.GetBankTransactionList()
.Where(b => b.Ignore == false && transactions.Contains(b.Id) == false)
.ToList();
return bankTransactions;
}
public async Task<List<Transaction>> MatchOpenBankTransactionsAsync()
public List<Transaction> MatchOpenBankTransactions()
{
var newTransactionList = new List<Transaction>();
var transactions = await transactionRepository.GetTransactionListAsync();
var accounts = await accountRepository.GetAccountListAsync();
var bookingRules = await bookingRuleRepository.GetBookingRuleListAsync();
var bankTransactions = (await GetOpenBankTransactionListAsync()).OrderBy(t => t.ValueDate).ToList();
var transactions = transactionRepository.GetTransactionList();
var accounts = accountRepository.GetAccountList();
var bookingRules = bookingRuleRepository.GetBookingRuleList();
var bankTransactions = GetOpenBankTransactionList().OrderBy(t => t.ValueDate);
foreach (var bankTransaction in bankTransactions)
{
var trx = new Transaction(bankTransaction, accounts);
@ -194,18 +228,14 @@ namespace Schaad.Accounting.Services
MatchBankTransactionBySameValueLastMonth(bankTransaction, trx, transactions);
newTransactionList.Add(trx);
}
var matched = newTransactionList.Count(t => string.IsNullOrEmpty(t.Text) == false);
logger.LogInformation("Matched {Matched} of {Total} open bank transactions", matched, bankTransactions.Count);
return newTransactionList;
}
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 bankTrx = await bankTransactionRepository.GetBankTransactionAsync(bankTransactionId);
var bookingRules = await bookingRuleRepository.GetBookingRuleListAsync();
var accounts = accountRepository.GetAccountList();
var bankTrx = bankTransactionRepository.GetBankTransaction(bankTransactionId);
var bookingRules = bookingRuleRepository.GetBookingRuleList();
var trxList = new List<Transaction>();
foreach (var creditCardTransaction in creditCardTransactions)

View file

@ -1,113 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.Repositories;
using Shouldly;
namespace Schaad.Accounting.Tests;
public class AccountRepositoryTestShould : IDisposable
{
private readonly string dbDir;
private readonly ISettingsService settingsService;
private readonly AccountRepository sut;
public AccountRepositoryTestShould()
{
dbDir = Path.Combine(Path.GetTempPath(), "AccountingNext-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dbDir);
settingsService = Substitute.For<ISettingsService>();
settingsService.GetDbPath().Returns(dbDir);
settingsService.GetLastYearDbPath().Returns(Path.Combine(dbDir, "no-such-dir"));
sut = new AccountRepository(settingsService, new RepositoryCache(), NullLogger<AccountRepository>.Instance);
}
public void Dispose()
{
if (Directory.Exists(dbDir))
{
Directory.Delete(dbDir, recursive: true);
}
}
[Fact]
public async Task AssignNewIdWhenSavingAccountWithoutId()
{
var account = new Account { Number = 1000, Name = "Cash", Currency = "CHF" };
await sut.SaveAccountAsync(account);
var stored = (await sut.GetAccountListAsync()).Single();
stored.Id.ShouldNotBeNullOrEmpty();
stored.Name.ShouldBe("Cash");
}
[Fact]
public async Task UpdateInPlaceWhenSavingAccountWithExistingId()
{
await sut.SaveAccountAsync(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
var id = (await sut.GetAccountListAsync()).Single().Id;
await sut.SaveAccountAsync(new Account { Id = id, Number = 1000, Name = "Cash renamed", Currency = "CHF" });
var accounts = await sut.GetAccountListAsync();
accounts.Count.ShouldBe(1);
accounts[0].Name.ShouldBe("Cash renamed");
}
[Fact]
public async Task DefaultCurrencyToChfWhenSavingAccountWithoutCurrency()
{
await sut.SaveAccountAsync(new Account { Number = 1000, Name = "Cash", Currency = null! });
(await sut.GetAccountListAsync()).Single().Currency.ShouldBe("CHF");
}
[Fact]
public async Task RemoveAccountWhenDeletingById()
{
await sut.SaveAccountAsync(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
var id = (await sut.GetAccountListAsync()).Single().Id;
await sut.DeleteAccountAsync(id);
(await sut.GetAccountListAsync()).ShouldBeEmpty();
}
[Fact]
public async Task ReturnAccountWhenLookingUpByBankAccountNumber()
{
await sut.SaveAccountAsync(new Account { Number = 1010, Name = "Checking", Currency = "CHF", BankAccountNumber = "CH01" });
await sut.SaveAccountAsync(new Account { Number = 1020, Name = "Savings", Currency = "CHF", BankAccountNumber = "CH02" });
(await sut.GetAccountForBankAccountNumberAsync("CH01")).Name.ShouldBe("Checking");
(await sut.GetAccountForBankAccountNumberAsync("CH02")).Name.ShouldBe("Savings");
(await sut.GetAccountForBankAccountNumberAsync("CH99")).ShouldBeNull();
}
[Fact]
public async Task UpdateLastBankBalanceWhenSavingBankBalance()
{
await sut.SaveAccountAsync(new Account { Number = 1010, Name = "Checking", Currency = "CHF", BankAccountNumber = "CH01" });
await sut.SaveBankAccountBalanceAsync("CH01", 1234.56m);
(await sut.GetAccountForBankAccountNumberAsync("CH01")).LastBankBalance.ShouldBe(1234.56m);
}
[Fact]
public async Task SurviveConstructorReRunWhenAccountsFileAlreadyExists()
{
await sut.SaveAccountAsync(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
// Re-construct with the same directory: existing file, no year rollover, no data loss.
var fresh = new AccountRepository(settingsService, new RepositoryCache(), NullLogger<AccountRepository>.Instance);
var accounts = await fresh.GetAccountListAsync();
accounts.Count.ShouldBe(1);
accounts[0].Name.ShouldBe("Cash");
}
}

View file

@ -1,317 +0,0 @@
using System.Linq;
using NSubstitute;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.Services;
using Shouldly;
namespace Schaad.Accounting.Tests;
public class ChartServiceTestShould
{
private readonly ISettingsService settingsService = Substitute.For<ISettingsService>();
private readonly IViewService viewService = Substitute.For<IViewService>();
public ChartServiceTestShould()
{
settingsService.GetYear().Returns(2026);
}
private ChartService BuildService() => new(settingsService, viewService);
[Fact]
public async Task HonourSelectedYearWhenBuildingMonthlySeries()
{
settingsService.GetYear().Returns(2023);
var groceries = MakeAccount("groc", 4200);
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset> { AsDataset(groceries), AsDataset(checking) });
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2023, 4, 5))
});
var series = await BuildService().GetExpensesPerMonthAsync();
series.Count.ShouldBe(1);
series[0].X.ShouldAllBe(x => ((DateOnly)x).Year == 2023);
settingsService.DidNotReceive().SetYear(Arg.Any<int>());
settingsService.DidNotReceive().TrySetYear(Arg.Any<int>());
}
[Fact]
public async Task GroupExpensesByAccountWhenAllExpensesShareOneSubClass()
{
var mannenbach = MakeAccount("m1", 4000);
var mannenbach2 = MakeAccount("m2", 4001);
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
{
AsDataset(mannenbach), AsDataset(mannenbach2), AsDataset(checking)
});
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, mannenbach, value: 10m, valueDate: new DateTime(2026, 1, 1)),
MakeTransaction(checking, mannenbach2, value: 20m, valueDate: new DateTime(2026, 1, 1))
});
var series = await BuildService().GetExpensesPerMonthAsync();
series.Count.ShouldBe(2);
series.ShouldContain(s => s.Id == "m1");
series.ShouldContain(s => s.Id == "m2");
}
[Fact]
public async Task GroupExpensesBySubClassWhenMultipleSubClassesAreUsed()
{
var groceries = MakeAccount("groc", 4200);
var utilities = MakeAccount("util", 4300);
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
{
AsDataset(groceries, subClassName: "Groceries"), AsDataset(utilities, subClassName: "Utilities"), AsDataset(checking)
});
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 1)),
MakeTransaction(checking, utilities, value: 30m, valueDate: new DateTime(2026, 2, 1))
});
var series = await BuildService().GetExpensesPerMonthAsync();
series.Count.ShouldBe(2);
series.ShouldContain(s => s.Id == "42" && s.Name == "Groceries");
series.ShouldContain(s => s.Id == "43" && s.Name == "Utilities");
}
[Fact]
public async Task SkipAccountsWithoutTransactionsWhenBuildingSeries()
{
var groceries = MakeAccount("groc", 4000);
var utilities = MakeAccount("util", 4001);
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
{
AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
});
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 1))
});
var series = await BuildService().GetExpensesPerMonthAsync();
series.Count.ShouldBe(1);
series[0].Id.ShouldBe("groc");
}
private static Account MakeAccount(string id, int number, string currency = "CHF") => new()
{
Id = id,
Number = number,
Name = id,
Currency = currency
};
private static AccountDataset AsDataset(Account a, string subClassName = "") =>
new(a, balance: 0, balanceCHF: 0, startBalanceCHF: 0, className: "", subClassName: subClassName);
private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, DateTime valueDate, decimal? fxRate = null, string text = "") =>
new(
new Transaction
{
Id = Guid.NewGuid().ToString(),
OriginAccountId = origin.Id,
TargetAccountId = target.Id,
Value = value,
FxRate = fxRate,
Text = text,
ValueDate = valueDate,
BookingDate = valueDate
},
origin, target);
[Fact]
public async Task ConvertFxAccountValuesWhenBuildingAccountSeries()
{
var usSubscription = MakeAccount("us", 4000, currency: "USD");
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset> { AsDataset(usSubscription), AsDataset(checking) });
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, usSubscription, value: 100m, valueDate: new DateTime(2026, 1, 1), fxRate: 0.5m)
});
var series = await BuildService().GetExpensesPerMonthAsync();
series.Single().Y.Single().ShouldBe(200m);
}
[Fact]
public async Task ConvertFxAccountValuesWhenBuildingSubClassSeries()
{
var usSubscription = MakeAccount("us", 4200, currency: "USD");
var utilities = MakeAccount("util", 4300);
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
{
AsDataset(usSubscription), AsDataset(utilities), AsDataset(checking)
});
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, usSubscription, value: 100m, valueDate: new DateTime(2026, 1, 1), fxRate: 0.5m),
MakeTransaction(checking, utilities, value: 30m, valueDate: new DateTime(2026, 2, 1))
});
var series = await BuildService().GetExpensesPerMonthAsync();
series.Single(s => s.Id == "42").Y[0].ShouldBe(200m);
}
[Fact]
public async Task GroupBookingTextExpensesByTextWhenBuildingMonthlySeriesForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"),
MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 2, 3), text: "Coop")
});
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
series.Count.ShouldBe(2);
var migros = series.Single(s => s.Name == "Migros");
migros.Y[0].ShouldBe(-50m);
migros.Y[1].ShouldBe(0m);
var coop = series.Single(s => s.Name == "Coop");
coop.Y[0].ShouldBe(0m);
coop.Y[1].ShouldBe(-20m);
}
[Fact]
public async Task GroupBookingTextsByFirstWordWhenBuildingMonthlySeriesForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Narbonne Car"),
MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 1, 6), text: "Narbonne Food")
});
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
var narbonne = series.Single(s => s.Name == "Narbonne");
narbonne.Y[0].ShouldBe(-70m);
}
[Fact]
public async Task ShowPositiveValueWhenAccountIsTargetOfBookingTextTransaction()
{
var checking = MakeAccount("chk", 1010);
var salary = MakeAccount("sal", 3000);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
{
MakeTransaction(salary, checking, value: 5000m, valueDate: new DateTime(2026, 1, 5), text: "Lohn")
});
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
series.Single().Y.Single().ShouldBe(5000m);
}
[Fact]
public async Task ConvertFxAccountValuesWhenBuildingBookingTextSeries()
{
var usChecking = MakeAccount("chk", 1010, currency: "USD");
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(usChecking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
{
MakeTransaction(usChecking, groceries, value: 100m, valueDate: new DateTime(2026, 1, 5), fxRate: 0.5m, text: "Migros")
});
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
series.Single().Y.Single().ShouldBe(-200m);
}
[Fact]
public async Task SumBookingTextTotalsAcrossMonthsWhenBuildingTotalsForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("groc").Returns(AsDataset(groceries));
viewService.GetTransactionViewListAsync("groc").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"),
MakeTransaction(checking, groceries, value: 30m, valueDate: new DateTime(2026, 2, 3), text: "Migros"),
MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 2, 4), text: "Coop")
});
var totals = await BuildService().GetBookingTextTotalsAsync("groc");
totals.Count.ShouldBe(2);
totals.ShouldContain(t => t.Name == "Migros" && t.Value == 80m);
totals.ShouldContain(t => t.Name == "Coop" && t.Value == 20m);
}
[Fact]
public async Task GroupBookingTextsByFirstWordWhenBuildingTotalsForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("groc").Returns(AsDataset(groceries));
viewService.GetTransactionViewListAsync("groc").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Narbonne Car"),
MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 1, 6), text: "Narbonne Food")
});
var totals = await BuildService().GetBookingTextTotalsAsync("groc");
totals.Single().Name.ShouldBe("Narbonne");
totals.Single().Value.ShouldBe(70m);
}
[Fact]
public async Task ExcludeBookingsWhereAccountIsOriginWhenBuildingTotalsForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("groc").Returns(AsDataset(groceries));
viewService.GetTransactionViewListAsync("groc").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"),
MakeTransaction(groceries, checking, value: 20m, valueDate: new DateTime(2026, 1, 6), text: "Migros")
});
var totals = await BuildService().GetBookingTextTotalsAsync("groc");
totals.Single().Value.ShouldBe(50m);
}
[Fact]
public async Task ExcludeZeroNetBookingTextsWhenBuildingTotalsForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("groc").Returns(AsDataset(groceries));
viewService.GetTransactionViewListAsync("groc").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"),
MakeTransaction(checking, groceries, value: -50m, valueDate: new DateTime(2026, 1, 6), text: "Migros")
});
var totals = await BuildService().GetBookingTextTotalsAsync("groc");
totals.ShouldBeEmpty();
}
}

View file

@ -1,98 +0,0 @@
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.Services;
using Schaad.Finance.Api;
using Shouldly;
namespace Schaad.Accounting.Tests;
public class FileServiceTestShould
{
private readonly ISettingsService settingsService = Substitute.For<ISettingsService>();
private readonly IAccountRepository accountRepo = Substitute.For<IAccountRepository>();
private readonly ITransactionRepository transactionRepo = Substitute.For<ITransactionRepository>();
private readonly IBankTransactionRepository bankTransactionRepo = Substitute.For<IBankTransactionRepository>();
private readonly IAccountStatementService accountStatementService = Substitute.For<IAccountStatementService>();
private readonly ICreditCardStatementService creditCardStatementService = Substitute.For<ICreditCardStatementService>();
public FileServiceTestShould()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
private FileService BuildService() => new(
settingsService, accountRepo, transactionRepo, bankTransactionRepo,
accountStatementService, creditCardStatementService, NullLogger<FileService>.Instance);
[Fact]
public async Task EmitHeaderRowAndRunningBalanceWhenExportingTransactionsCsv()
{
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF", StartBalance = 1000m };
accountRepo.GetAccountAsync("chk").Returns(checking);
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
{
new()
{
Id = "t1", OriginAccountId = "chk", TargetAccountId = "groc", Value = 50m, Text = "Coop",
BookingDate = new DateTime(2026, 1, 5), ValueDate = new DateTime(2026, 1, 5)
},
new()
{
Id = "t2", OriginAccountId = "sal", TargetAccountId = "chk", Value = 5000m, Text = "Salary",
BookingDate = new DateTime(2026, 1, 25), ValueDate = new DateTime(2026, 1, 25)
}
});
var bytes = await BuildService().GetTransactionListCsvAsync("chk");
var csv = Encoding.GetEncoding("ISO-8859-1").GetString(bytes);
var lines = csv.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
lines[0].ShouldBe("Buchungsdatum;Valuta;Buchungstext;Belastung;Gutschrift;Saldo CHF");
lines[1].ShouldBe(";;Startsaldo;;;1000");
lines[2].ShouldBe("05.01.2026;05.01.2026;Coop;50.00;;950.00");
lines[3].ShouldBe("25.01.2026;25.01.2026;Salary;;5'000.00;5'950.00");
}
[Fact]
public async Task OrderTransactionsByBookingDateThenValueDateThenValueWhenExporting()
{
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF", StartBalance = 0m };
accountRepo.GetAccountAsync("chk").Returns(checking);
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
{
new() { OriginAccountId = "chk", TargetAccountId = "x", Value = 10m, Text = "later",
BookingDate = new DateTime(2026, 2, 1), ValueDate = new DateTime(2026, 2, 1) },
new() { OriginAccountId = "chk", TargetAccountId = "x", Value = 5m, Text = "earlier small",
BookingDate = new DateTime(2026, 1, 15), ValueDate = new DateTime(2026, 1, 15) },
new() { OriginAccountId = "chk", TargetAccountId = "x", Value = 20m, Text = "earlier large",
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 lines = csv.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
lines[2].ShouldContain("earlier small");
lines[3].ShouldContain("earlier large");
lines[4].ShouldContain("later");
}
[Fact]
public async Task NotMutateTransactionValueWhenExportingCsv()
{
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF" };
accountRepo.GetAccountAsync("chk").Returns(checking);
var outgoing = new Transaction
{
OriginAccountId = "chk", TargetAccountId = "x", Value = 42m, Text = "test",
BookingDate = new DateTime(2026, 1, 1), ValueDate = new DateTime(2026, 1, 1)
};
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction> { outgoing });
await BuildService().GetTransactionListCsvAsync("chk");
outgoing.Value.ShouldBe(42m);
}
}

View file

@ -1,41 +0,0 @@
using System.Globalization;
using Shouldly;
namespace Schaad.Accounting.Tests;
public class FormattingTestShould
{
[Theory]
[InlineData(0, "0.00")]
[InlineData(1.5, "1.50")]
[InlineData(-1.5, "-1.50")]
[InlineData(1234.5, "1'234.50")]
[InlineData(1234567.89, "1'234'567.89")]
[InlineData(-1234567.89, "-1'234'567.89")]
public void FormatDecimalWithSwissThousandsSeparatorWhenCalled(decimal value, string expected)
{
value.ToFormattedString().ShouldBe(expected);
}
[Fact]
public void RoundToTwoDecimalsWhenValueHasMorePrecision()
{
1.234m.ToFormattedString().ShouldBe("1.23");
1.235m.ToFormattedString().ShouldBe("1.24");
}
[Fact]
public void IgnoreAmbientCultureWhenFormatting()
{
var previous = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
1234.5m.ToFormattedString().ShouldBe("1'234.50");
}
finally
{
CultureInfo.CurrentCulture = previous;
}
}
}

View file

@ -1,34 +0,0 @@
using NSubstitute;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Services;
using Schaad.Finance.Api;
using Shouldly;
namespace Schaad.Accounting.Tests;
public class FxConverterTestShould
{
private readonly IFxService fxService = Substitute.For<IFxService>();
private readonly SettingsDataset settings = new() { DataPath = "", FixerIoApiKey = "test-key" };
private FxConverter BuildConverter() => new(fxService, settings);
[Fact]
public void DelegateToFxServiceWithChfAsTargetAndConfiguredApiKeyWhenConverting()
{
fxService.ConvertCurrency(100m, "USD", "CHF", "test-key").Returns(90m);
var result = BuildConverter().ConvertToChf(100m, "USD");
result.ShouldBe(90m);
fxService.Received(1).ConvertCurrency(100m, "USD", "CHF", "test-key");
}
[Fact]
public void PassNegativeAmountsThroughWhenConverting()
{
fxService.ConvertCurrency(-50m, "EUR", "CHF", Arg.Any<string>()).Returns(-48m);
BuildConverter().ConvertToChf(-50m, "EUR").ShouldBe(-48m);
}
}

View file

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

View file

@ -1,35 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />
<ProjectReference Include="..\Schaad.Accounting.Db\Schaad.Accounting.Db.csproj" />
<ProjectReference Include="..\Schaad.Accounting.Services\Schaad.Accounting.Services.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="Schaad.Finance.Api">
<HintPath>..\dlls\Schaad.Finance.Api.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View file

@ -1,85 +0,0 @@
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Models;
using Shouldly;
namespace Schaad.Accounting.Tests;
public class TransactionDatasetTestShould
{
private static Account MakeAccount(string id) => new() { Id = id, Name = id, Currency = "CHF" };
private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, decimal? fxRate = null, string text = "") =>
new(
new Transaction
{
Id = Guid.NewGuid().ToString(),
OriginAccountId = origin.Id,
TargetAccountId = target.Id,
Value = value,
FxRate = fxRate,
Text = text,
ValueDate = DateTime.Now,
BookingDate = DateTime.Now
},
origin, target);
[Fact]
public void ReturnNegativeValueWhenAccountIsOrigin()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(checking, groceries, value: 50m);
transaction.GetValue(withFxRate: false, accountId: "chk").ShouldBe(-50m);
}
[Fact]
public void ReturnPositiveValueWhenAccountIsTarget()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(groceries, checking, value: 50m);
transaction.GetValue(withFxRate: false, accountId: "chk").ShouldBe(50m);
}
[Fact]
public void ApplyFxConversionBeforeApplyingSignWhenAccountIsOrigin()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(checking, groceries, value: 100m, fxRate: 0.5m);
transaction.GetValue(withFxRate: true, accountId: "chk").ShouldBe(-200m);
}
[Fact]
public void ReturnFirstWordWhenTextHasMultipleWords()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(checking, groceries, value: 50m, text: "Narbonne Car");
transaction.GetTextGroup().ShouldBe("Narbonne");
}
[Fact]
public void ReturnWholeTextWhenTextHasNoSpace()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(checking, groceries, value: 50m, text: "Migros");
transaction.GetTextGroup().ShouldBe("Migros");
}
[Fact]
public void ReturnEmptyStringWhenTextIsEmpty()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(checking, groceries, value: 50m, text: "");
transaction.GetTextGroup().ShouldBe("");
}
}

View file

@ -1,173 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.Repositories;
using Shouldly;
namespace Schaad.Accounting.Tests;
public class TransactionRepositoryTestShould : IDisposable
{
private readonly string dbDir;
private readonly ISettingsService settingsService;
private readonly IAccountRepository accountRepo;
private readonly RepositoryCache cache;
private readonly TransactionRepository sut;
public TransactionRepositoryTestShould()
{
dbDir = Path.Combine(Path.GetTempPath(), "AccountingNext-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dbDir);
settingsService = Substitute.For<ISettingsService>();
settingsService.GetDbPath().Returns(dbDir);
settingsService.GetLastYearDbPath().Returns(Path.Combine(dbDir, "no-such-dir"));
accountRepo = Substitute.For<IAccountRepository>();
cache = new RepositoryCache();
sut = new TransactionRepository(settingsService, cache, accountRepo, NullLogger<TransactionRepository>.Instance);
}
public void Dispose()
{
if (Directory.Exists(dbDir))
{
Directory.Delete(dbDir, recursive: true);
}
}
[Fact]
public async Task PreserveValueWhenRoundTrippingNonFxTransaction()
{
accountRepo.GetAccountAsync("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
accountRepo.GetAccountAsync("groc").Returns(new Account { Id = "groc", Currency = "CHF" });
await sut.SaveTransactionAsync(new Transaction
{
OriginAccountId = "chk",
TargetAccountId = "groc",
Value = 42m,
Text = "Groceries",
ValueDate = new DateTime(2026, 5, 10),
BookingDate = new DateTime(2026, 5, 10)
});
var stored = (await sut.GetTransactionListAsync()).Single();
var loaded = await sut.GetTransactionAsync(stored.Id);
loaded.ShouldNotBeNull();
loaded.Value.ShouldBe(42m);
loaded.FxRate.ShouldBeNull();
}
[Fact]
public async Task StoreInChfAndReturnInAccountCurrencyWhenTransactionIsFx()
{
accountRepo.GetAccountAsync("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
accountRepo.GetAccountAsync("usd").Returns(new Account { Id = "usd", Currency = "USD" });
await sut.SaveTransactionAsync(new Transaction
{
OriginAccountId = "chk",
TargetAccountId = "usd",
Value = 100m,
FxRate = 0.9m,
Text = "USD transfer",
ValueDate = new DateTime(2026, 5, 10),
BookingDate = new DateTime(2026, 5, 10)
});
var stored = (await sut.GetTransactionListAsync()).Single();
stored.Value.ShouldBe(90m);
var loaded = await sut.GetTransactionAsync(stored.Id);
loaded.ShouldNotBeNull();
loaded.Value.ShouldBe(100m);
loaded.FxRate.ShouldBe(0.9m);
}
[Fact]
public async Task ReturnNullWhenGettingUnknownTransactionId()
{
(await sut.GetTransactionAsync("no-such-id")).ShouldBeNull();
}
[Fact]
public async Task NotMutateStoredValueWhenGettingFxTransactionTwice()
{
accountRepo.GetAccountAsync("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
accountRepo.GetAccountAsync("usd").Returns(new Account { Id = "usd", Currency = "USD" });
await sut.SaveTransactionAsync(new Transaction
{
OriginAccountId = "chk",
TargetAccountId = "usd",
Value = 100m,
FxRate = 0.9m,
Text = "USD transfer",
ValueDate = new DateTime(2026, 5, 10),
BookingDate = new DateTime(2026, 5, 10)
});
var stored = (await sut.GetTransactionListAsync()).Single();
var id = stored.Id;
var first = await sut.GetTransactionAsync(id);
var second = await sut.GetTransactionAsync(id);
first.ShouldNotBeNull();
first.Value.ShouldBe(100m);
second.ShouldNotBeNull();
second.Value.ShouldBe(100m);
}
[Fact]
public async Task RemoveTransactionWhenDeletingById()
{
accountRepo.GetAccountAsync(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
await sut.SaveTransactionAsync(new Transaction { OriginAccountId = "a", TargetAccountId = "b", Value = 1m, Text = "x", ValueDate = DateTime.Today });
var id = (await sut.GetTransactionListAsync()).Single().Id;
await sut.DeleteTransactionAsync(id);
(await sut.GetTransactionListAsync()).ShouldBeEmpty();
}
[Fact]
public async Task PreserveRelatedPartyWhenRoundTrippingTransaction()
{
accountRepo.GetAccountAsync(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
await sut.SaveTransactionAsync(new Transaction
{
OriginAccountId = "a",
TargetAccountId = "b",
Value = 10m,
Text = "Rent",
RelatedParty = "ACME Property AG",
ValueDate = new DateTime(2026, 4, 1),
BookingDate = new DateTime(2026, 4, 1)
});
(await sut.GetTransactionListAsync()).Single().RelatedParty.ShouldBe("ACME Property AG");
}
[Fact]
public async Task DefaultBookingDateToValueDateWhenBookingDateIsUnset()
{
accountRepo.GetAccountAsync(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
await sut.SaveTransactionAsync(new Transaction
{
OriginAccountId = "a",
TargetAccountId = "b",
Value = 10m,
Text = "x",
ValueDate = new DateTime(2026, 4, 15)
});
(await sut.GetTransactionListAsync()).Single().BookingDate.ShouldBe(new DateTime(2026, 4, 15));
}
}

View file

@ -1,200 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.Services;
using Shouldly;
namespace Schaad.Accounting.Tests;
public class ViewServiceTestShould
{
private readonly IAccountRepository accountRepo = Substitute.For<IAccountRepository>();
private readonly ITransactionRepository transactionRepo = Substitute.For<ITransactionRepository>();
private readonly IBankTransactionRepository bankTransactionRepo = Substitute.For<IBankTransactionRepository>();
private readonly IBookingRuleRepository bookingRuleRepo = Substitute.For<IBookingRuleRepository>();
private readonly ISubclassRepository subclassRepo = Substitute.For<ISubclassRepository>();
private readonly IFxConverter fxConverter = Substitute.For<IFxConverter>();
public ViewServiceTestShould()
{
subclassRepo.GetSubClassListAsync().Returns(
Enumerable.Range(10, 50).Select(n => new SubClass { Number = n, Name = "sub-" + n }).ToList());
subclassRepo.GetClass(Arg.Any<int>()).Returns("");
fxConverter.ConvertToChf(Arg.Any<decimal>(), Arg.Any<string>())
.Returns(ci => ci.ArgAt<decimal>(0));
}
private ViewService BuildService() =>
new(accountRepo, bankTransactionRepo, transactionRepo, subclassRepo, bookingRuleRepo, fxConverter, NullLogger<ViewService>.Instance);
// --- Balance math ---------------------------------------------------------
[Fact]
public async Task SumActivaAndPassivaTotalsSeparatelyWhenGettingBalanceView()
{
var cash = Account("cash", 1000, "CHF", startBalance: 100m);
var receivables = Account("recv", 1100, "CHF", startBalance: 50m);
var payables = Account("pay", 2000, "CHF", startBalance: -200m);
accountRepo.GetAccountListAsync().Returns(new List<Account> { cash, receivables, payables });
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>());
var view = await BuildService().GetBalanceViewAsync();
view.ActivaAccountList.Count.ShouldBe(2);
view.PassivaAccountList.Count.ShouldBe(1);
view.TotalActivaCHF.ShouldBe(150m);
view.TotalPassivaCHF.ShouldBe(200m);
}
[Fact]
public async Task ApplyTransactionsToStartBalanceWhenComputingAccountBalance()
{
var cash = Account("cash", 1000, "CHF", startBalance: 1000m);
var groceries = Account("groc", 4000, "CHF");
accountRepo.GetAccountListAsync().Returns(new List<Account> { cash, groceries });
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
{
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) },
});
var accounts = await BuildService().GetAccountViewListAsync();
accounts.Single(a => a.Id == "cash").Balance.ShouldBe(920m);
accounts.Single(a => a.Id == "groc").Balance.ShouldBe(80m);
}
[Fact]
public async Task ConvertBalanceToChfWhenAccountUsesForeignCurrency()
{
var usd = Account("usd", 1050, "USD", startBalance: 100m);
accountRepo.GetAccountListAsync().Returns(new List<Account> { usd });
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>());
fxConverter.ConvertToChf(Arg.Any<decimal>(), "USD")
.Returns(ci => ci.ArgAt<decimal>(0) * 0.9m);
var account = (await BuildService().GetAccountViewListAsync()).Single();
account.Balance.ShouldBe(100m);
account.BalanceCHF.ShouldBe(90m);
account.StartBalanceCHF.ShouldBe(90m);
}
// --- Bank-transaction auto-matching --------------------------------------
[Fact]
public async Task MatchBankTransactionByBookingRuleWhenLookupTextIsContained()
{
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var utilities = Account("util", 4200, "CHF");
accountRepo.GetAccountListAsync().Returns(new List<Account> { checking, utilities });
bookingRuleRepo.GetBookingRuleListAsync().Returns(new List<BookingRule>
{
new() { Id = "r1", LookupText = "electric", AccountId = "util", BookingText = "Strom" }
});
bankTransactionRepo.GetBankTransactionListAsync().Returns(new List<BankTransaction>
{
new() { Id = "b1", Text = "Monthly Electric Bill", Value = -85m, BankAccountNumber = "CH01",
ValueDate = new DateTime(2026, 3, 5), BookingDate = new DateTime(2026, 3, 5) }
});
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>());
var matched = (await BuildService().MatchOpenBankTransactionsAsync()).Single();
matched.Text.ShouldBe("Strom");
matched.OriginAccountId.ShouldBe("chk");
matched.TargetAccountId.ShouldBe("util");
}
[Fact]
public async Task PreferBookingRuleWithMatchingValueWhenMultipleRulesMatchText()
{
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var rent = Account("rent", 4100, "CHF");
var groceries = Account("groc", 4200, "CHF");
accountRepo.GetAccountListAsync().Returns(new List<Account> { checking, rent, groceries });
bookingRuleRepo.GetBookingRuleListAsync().Returns(new List<BookingRule>
{
new() { Id = "r1", LookupText = "coop", LookupValue = 0m, AccountId = "groc", BookingText = "Groceries" },
new() { Id = "r2", LookupText = "coop", LookupValue = 1200m, AccountId = "rent", BookingText = "Rent via Coop" }
});
bankTransactionRepo.GetBankTransactionListAsync().Returns(new List<BankTransaction>
{
new() { Id = "b1", Text = "COOP payment", Value = -1200m, BankAccountNumber = "CH01",
ValueDate = new DateTime(2026, 3, 1), BookingDate = new DateTime(2026, 3, 1) }
});
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>());
var matched = (await BuildService().MatchOpenBankTransactionsAsync()).Single();
matched.Text.ShouldBe("Rent via Coop");
matched.TargetAccountId.ShouldBe("rent");
}
[Fact]
public async Task FallBackToSameAccountsLastMonthWhenNoBookingRuleMatches()
{
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var salary = Account("sal", 3000, "CHF");
accountRepo.GetAccountListAsync().Returns(new List<Account> { checking, salary });
bookingRuleRepo.GetBookingRuleListAsync().Returns(new List<BookingRule>());
bankTransactionRepo.GetBankTransactionListAsync().Returns(new List<BankTransaction>
{
new() { Id = "b2", Text = "Salary Feb", Value = 5000m, BankAccountNumber = "CH01",
ValueDate = new DateTime(2026, 2, 25), BookingDate = new DateTime(2026, 2, 25) }
});
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
{
new()
{
Id = "t1",
OriginAccountId = "sal",
TargetAccountId = "chk",
Value = 5000m,
Text = "Monatslohn",
ValueDate = new DateTime(2026, 1, 25),
BookingDate = new DateTime(2026, 1, 25)
}
});
var matched = (await BuildService().MatchOpenBankTransactionsAsync()).Single();
matched.Text.ShouldBe("Monatslohn");
}
[Fact]
public async Task ExcludeIgnoredAndAlreadyBookedWhenGettingOpenBankTransactions()
{
bankTransactionRepo.GetBankTransactionListAsync().Returns(new List<BankTransaction>
{
new() { Id = "open", Text = "still open", Ignore = false },
new() { Id = "ignored", Text = "ignore me", Ignore = true },
new() { Id = "booked", Text = "already booked", Ignore = false }
});
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
{
new() { Id = "t1", BankTransactionId = "booked", OriginAccountId = "a", TargetAccountId = "b", Value = 10 }
});
var open = await BuildService().GetOpenBankTransactionListAsync();
open.Count.ShouldBe(1);
open[0].Id.ShouldBe("open");
}
private static Account Account(string id, int number, string currency, decimal startBalance = 0m, string? bankAccountNumber = null)
=> new()
{
Id = id,
Number = number,
Name = id,
Currency = currency,
StartBalance = startBalance,
BankAccountNumber = bankAccountNumber
};
}

View file

@ -1,6 +1,4 @@
@inherits LayoutComponentBase
@inject NavigationManager Navigation
@implements IDisposable
<FluentLayout>
<FluentHeader Class="hidePrint">
@ -11,24 +9,7 @@
<FluentBodyContent Class="body-content">
<FluentMessageBarProvider Section="MESSAGES_TOP" @rendermode="InteractiveServer" />
<div class="content">
<ErrorBoundary @ref="errorBoundary">
<ChildContent>
@Body
</ChildContent>
<ErrorContent>
<FluentStack Orientation="Orientation.Vertical" Style="padding: 2rem; max-width: 640px;">
<FluentMessageBar Intent="MessageIntent.Error"
Title="Ein Fehler ist aufgetreten"
AllowDismiss="false">
Diese Seite konnte nicht angezeigt werden. Details stehen im Server-Log.
</FluentMessageBar>
<FluentStack Orientation="Orientation.Horizontal">
<FluentButton Appearance="Appearance.Neutral" OnClick="Recover">Neu versuchen</FluentButton>
<FluentAnchor Href="/" Appearance="Appearance.Neutral">Zur Startseite</FluentAnchor>
</FluentStack>
</FluentStack>
</ErrorContent>
</ErrorBoundary>
</div>
</FluentBodyContent>
</FluentStack>
@ -48,22 +29,3 @@
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
@code {
private ErrorBoundary? errorBoundary;
protected override void OnInitialized()
{
// ErrorBoundary state persists across NavigationManager.NavigateTo, so the
// error page would linger even after the user clicked "Zur Startseite".
// Reset on any location change.
Navigation.LocationChanged += OnLocationChanged;
}
private void OnLocationChanged(object? sender, Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs e)
=> errorBoundary?.Recover();
private void Recover() => errorBoundary?.Recover();
public void Dispose() => Navigation.LocationChanged -= OnLocationChanged;
}

View file

@ -1,9 +0,0 @@
@* Consistent placeholder for async-loaded pages/dialogs. Replaces the
ad-hoc `<p>Lädt…</p>` sprinkled across pages after PR P made every
OnInitializedAsync truly async. Kept as a pure visual — callers keep
their existing null-guard and just render this while data is loading. *@
<div style="display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 2rem;">
<FluentProgressRing/>
<FluentLabel>Lädt…</FluentLabel>
</div>

View file

@ -3,13 +3,10 @@
<PageTitle>Konten</PageTitle>
<FluentStack Orientation="Orientation.Horizontal" VerticalAlignment="VerticalAlignment.Center">
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neues Konto hinzufügen</FluentButton>
<FluentSearch @bind-Value="filter" Immediate="true" Placeholder="Filter…" Style="width: 300px;"/>
</FluentStack>
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neues Konto hinzufügen</FluentButton>
<FluentPaginator State="@pagination" SummaryTemplate="@template" />
<FluentDataGrid Items="@Filtered" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
<FluentDataGrid Items="@accountQueryable" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
<PropertyColumn Property="@(p => p.Class)" Sortable="true" Width="100px" />
<PropertyColumn Property="@(p => p.SubClass)" Sortable="true" Width="140px" />
<PropertyColumn Property="@(p => p.Number)" Sortable="true" IsDefaultSortColumn="true" Width="140px" />
@ -19,10 +16,10 @@
<PropertyColumn Property="@(p => p.StartBalance)" Format="C2"/>
<PropertyColumn Property="@(p => p.LastBankBalance)" Format="C2" />
<TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" title="Löschen" aria-label="Löschen" OnClick="@(() => DeleteAsync(context.Id))" />
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" OnClick="@(() => DeleteAsync(context.Id))" />
</TemplateColumn>
<TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" title="Bearbeiten" aria-label="Bearbeiten" OnClick="@(() => EditAsync(context.Id))" />
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" OnClick="@(() => EditAsync(context.Id))" />
</TemplateColumn>
</FluentDataGrid>

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -14,24 +14,12 @@ public partial class Accounts : ComponentBase
[Inject]
private IDialogService dialogService { get; set; } = null!;
[Inject]
private IToastService toastService { get; set; } = null!;
private IQueryable<Account>? accountQueryable;
private IReadOnlyList<Account> source = [];
private string filter = "";
private IQueryable<Account> Filtered =>
string.IsNullOrWhiteSpace(filter)
? source.AsQueryable()
: source.AsQueryable().Where(a =>
a.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
a.Number.ToString().Contains(filter) ||
(a.BankAccountNumber != null && a.BankAccountNumber.Contains(filter, StringComparison.OrdinalIgnoreCase)));
protected override async Task OnInitializedAsync()
protected override Task OnInitializedAsync()
{
source = await accountRepository.GetAccountListAsync();
await base.OnInitializedAsync();
accountQueryable = accountRepository.GetAccountList().AsQueryable();
return base.OnInitializedAsync();
}
private async Task AddAsync()
@ -48,14 +36,13 @@ public partial class Accounts : ComponentBase
var result = await dialog.Result;
if (!result.Cancelled && result.Data != null)
{
source = await accountRepository.GetAccountListAsync();
toastService.ShowToast(ToastIntent.Success, "Konto gespeichert", 3000);
accountQueryable = accountRepository.GetAccountList().AsQueryable();
}
}
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()
{
Height = "500px",
@ -67,21 +54,19 @@ public partial class Accounts : ComponentBase
var result = await dialog.Result;
if (!result.Cancelled && result.Data != null)
{
source = await accountRepository.GetAccountListAsync();
toastService.ShowToast(ToastIntent.Success, "Konto gespeichert", 3000);
accountQueryable = accountRepository.GetAccountList().AsQueryable();
}
}
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 result = await dialog.Result;
if (!result.Cancelled)
{
await accountRepository.DeleteAccountAsync(id);
source = await accountRepository.GetAccountListAsync();
toastService.ShowToast(ToastIntent.Success, "Konto gelöscht", 3000);
accountRepository.DeleteAccount(id);
accountQueryable = accountRepository.GetAccountList().AsQueryable();
}
}
}

View file

@ -1,11 +1,10 @@
@page "/banktransactions"
<PageTitle>BankTransactions</PageTitle>
<FluentSearch @bind-Value="filter" Immediate="true" Placeholder="Filter…" Style="width: 300px;"/>
<FluentPaginator State="@pagination" />
<div id="datagrid-container">
<FluentDataGrid Items="@Filtered" Pagination="@pagination" RowSize="DataGridRowSize.Small" AutoItemsPerPage="true" Style="overflow-y:hidden;">
<FluentDataGrid Items="@TransactionList" Pagination="@pagination" RowSize="DataGridRowSize.Small" AutoItemsPerPage="true" Style="overflow-y:hidden;">
<PropertyColumn Title="Buchungsdatum" Width="170px" Property="@(p => p.BookingDate)" Format="dd.MM.yyyy" Sortable="true" IsDefaultSortColumn="true" InitialSortDirection="SortDirection.Descending" />
<PropertyColumn Title="Valutadatum" Width="170px" Property="@(p => p.ValueDate)" Format="dd.MM.yyyy" Sortable="true" />
<PropertyColumn Title="Text" Property="@(p => p.Text)" />

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -8,22 +8,11 @@ public partial class BankTransactions : ComponentBase
{
[Inject]
private IBankTransactionRepository bankTransactionRepository { get; set; } = null!;
private IQueryable<BankTransaction>? TransactionList;
private IReadOnlyList<BankTransaction> source = [];
private string filter = "";
private IQueryable<BankTransaction> Filtered =>
string.IsNullOrWhiteSpace(filter)
? source.AsQueryable()
: source.AsQueryable().Where(t =>
(t.Text != null && t.Text.Contains(filter, StringComparison.OrdinalIgnoreCase)) ||
(t.Debtor != null && t.Debtor.Contains(filter, StringComparison.OrdinalIgnoreCase)) ||
(t.Creditor != null && t.Creditor.Contains(filter, StringComparison.OrdinalIgnoreCase)) ||
t.Value.ToString().Contains(filter));
protected override async Task OnInitializedAsync()
protected override Task OnInitializedAsync()
{
source = await bankTransactionRepository.GetBankTransactionListAsync();
await base.OnInitializedAsync();
TransactionList = bankTransactionRepository.GetBankTransactionList().AsQueryable();
return base.OnInitializedAsync();
}
}

View file

@ -3,22 +3,19 @@
<PageTitle>Buchungsregeln</PageTitle>
<FluentStack Orientation="Orientation.Horizontal" VerticalAlignment="VerticalAlignment.Center">
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neue Regel hinzufügen</FluentButton>
<FluentSearch @bind-Value="filter" Immediate="true" Placeholder="Filter…" Style="width: 300px;"/>
</FluentStack>
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neue Regel hinzufügen</FluentButton>
<FluentPaginator State="@pagination" SummaryTemplate="@template" />
<FluentDataGrid Items="@Filtered" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
<FluentDataGrid Items="@bookingRulesQueryable" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
<PropertyColumn Property="@(p => p.LookupText)" Sortable="true" IsDefaultSortColumn="true" />
<PropertyColumn Property="@(p => p.LookupValue)" Format="C2" Align="Align.End"/>
<PropertyColumn Property="@(p => p.BookingText)" />
<PropertyColumn Property="@(p => p.Account)" />
<TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" title="Löschen" aria-label="Löschen" OnClick="@(() => DeleteAsync(context.Id))" />
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" OnClick="@(() => DeleteAsync(context.Id))" />
</TemplateColumn>
<TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" title="Bearbeiten" aria-label="Bearbeiten" OnClick="@(() => EditAsync(context.Id))" />
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" OnClick="@(() => EditAsync(context.Id))" />
</TemplateColumn>
</FluentDataGrid>

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces;
@ -18,24 +18,12 @@ public partial class BookingRules : ComponentBase
[Inject]
private IDialogService dialogService { get; set; } = null!;
[Inject]
private IToastService toastService { get; set; } = null!;
private IQueryable<BookingRuleDataset>? bookingRulesQueryable;
private IReadOnlyList<BookingRuleDataset> source = [];
private string filter = "";
private IQueryable<BookingRuleDataset> Filtered =>
string.IsNullOrWhiteSpace(filter)
? source.AsQueryable()
: source.AsQueryable().Where(r =>
r.LookupText.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
(r.BookingText != null && r.BookingText.Contains(filter, StringComparison.OrdinalIgnoreCase)) ||
(r.Account != null && r.Account.Contains(filter, StringComparison.OrdinalIgnoreCase)));
protected override async Task OnInitializedAsync()
protected override Task OnInitializedAsync()
{
source = await viewService.GetBookingRuleViewListAsync();
await base.OnInitializedAsync();
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable();
return base.OnInitializedAsync();
}
private async Task AddAsync()
@ -52,14 +40,13 @@ public partial class BookingRules : ComponentBase
var result = await dialog.Result;
if (!result.Cancelled && result.Data != null)
{
source = await viewService.GetBookingRuleViewListAsync();
toastService.ShowToast(ToastIntent.Success, "Buchungsregel gespeichert", 3000);
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable();
}
}
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()
{
Height = "500px",
@ -71,21 +58,19 @@ public partial class BookingRules : ComponentBase
var result = await dialog.Result;
if (!result.Cancelled && result.Data != null)
{
source = await viewService.GetBookingRuleViewListAsync();
toastService.ShowToast(ToastIntent.Success, "Buchungsregel gespeichert", 3000);
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable();
}
}
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 result = await dialog.Result;
if (!result.Cancelled)
{
await bookingRuleRepository.DeleteBookingRuleAsync(id);
source = await viewService.GetBookingRuleViewListAsync();
toastService.ShowToast(ToastIntent.Success, "Buchungsregel gelöscht", 3000);
bookingRuleRepository.DeleteBookingRule(id);
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable();
}
}
}

View file

@ -3,19 +3,16 @@
<PageTitle>Buchungstexte</PageTitle>
<FluentStack Orientation="Orientation.Horizontal" VerticalAlignment="VerticalAlignment.Center">
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neuer Text hinzufügen</FluentButton>
<FluentSearch @bind-Value="filter" Immediate="true" Placeholder="Filter…" Style="width: 300px;"/>
</FluentStack>
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neuer Text hinzufügen</FluentButton>
<FluentPaginator State="@pagination" SummaryTemplate="@template" />
<FluentDataGrid Items="@Filtered" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
<FluentDataGrid Items="@bookingTextQueryable" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
<PropertyColumn Property="@(p => p.Text)" Sortable="true" IsDefaultSortColumn="true" />
<TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" title="Löschen" aria-label="Löschen" OnClick="@(() => DeleteAsync(context.Id))" />
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" OnClick="@(() => DeleteAsync(context.Id))" />
</TemplateColumn>
<TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" title="Bearbeiten" aria-label="Bearbeiten" OnClick="@(() => EditAsync(context.Id))" />
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" OnClick="@(() => EditAsync(context.Id))" />
</TemplateColumn>
</FluentDataGrid>

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -14,21 +14,12 @@ public partial class BookingTexts : ComponentBase
[Inject]
private IDialogService dialogService { get; set; } = null!;
[Inject]
private IToastService toastService { get; set; } = null!;
private IQueryable<BookingText>? bookingTextQueryable;
private IReadOnlyList<BookingText> source = [];
private string filter = "";
private IQueryable<BookingText> Filtered =>
string.IsNullOrWhiteSpace(filter)
? source.AsQueryable()
: source.AsQueryable().Where(t => t.Text.Contains(filter, StringComparison.OrdinalIgnoreCase));
protected override async Task OnInitializedAsync()
protected override Task OnInitializedAsync()
{
source = await bookingTextRepository.GetBookingTextListAsync();
await base.OnInitializedAsync();
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable();
return base.OnInitializedAsync();
}
private async Task AddAsync()
@ -45,14 +36,13 @@ public partial class BookingTexts : ComponentBase
var result = await dialog.Result;
if (!result.Cancelled && result.Data != null)
{
source = await bookingTextRepository.GetBookingTextListAsync();
toastService.ShowToast(ToastIntent.Success, "Buchungstext gespeichert", 3000);
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable();
}
}
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()
{
Height = "500px",
@ -64,21 +54,19 @@ public partial class BookingTexts : ComponentBase
var result = await dialog.Result;
if (!result.Cancelled && result.Data != null)
{
source = await bookingTextRepository.GetBookingTextListAsync();
toastService.ShowToast(ToastIntent.Success, "Buchungstext gespeichert", 3000);
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable();
}
}
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 result = await dialog.Result;
if (!result.Cancelled)
{
await bookingTextRepository.DeleteBookingTextAsync(id);
source = await bookingTextRepository.GetBookingTextListAsync();
toastService.ShowToast(ToastIntent.Success, "Buchungstext gelöscht", 3000);
bookingTextRepository.DeleteBookingText(id);
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable();
}
}
}

View file

@ -1,15 +1,6 @@
@page "/Assets"
@page "/Assets"
@rendermode InteractiveServer
<PageTitle>Vermögen</PageTitle>
<PageTitle>Ausgaben</PageTitle>
<FluentCard Style="padding: 16px; max-width: 900px;">
@if (config is not null && layout is not null && data is not null)
{
<PlotlyChart style="height: 560px; min-height: 420px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
}
else
{
<Loading/>
}
</FluentCard>
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />

View file

@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components;
using Plotly.Blazor;
using Plotly.Blazor.Traces;
using Schaad.Accounting.Datasets.Charts;
using Schaad.Accounting.Interfaces;
namespace Schaad.Accounting.UI.Components.Pages.Charts;
@ -9,26 +8,48 @@ namespace Schaad.Accounting.UI.Components.Pages.Charts;
public partial class Assets : ComponentBase
{
private PlotlyChart chart = null!;
private Config? config;
private Plotly.Blazor.Layout? layout;
private IList<ITrace>? data;
private Config config = null!;
private Plotly.Blazor.Layout layout = null!;
private IList<ITrace> data = null!;
[Inject]
private IViewService viewService { get; set; } = null!;
protected override async Task OnInitializedAsync()
protected override Task OnInitializedAsync()
{
config = ChartTheme.BaseConfig();
layout = ChartTheme.BaseLayout("Vermögen nach Konto");
config = new Config
{
Responsive = true
};
var accounts = (await viewService.GetAccountViewListAsync()).Where(a => a.Class == ClassIds.Activa);
var totals = accounts
.Where(a => a.BalanceCHF > 0)
.Select(a => new CategoryTotal(a.Name, a.BalanceCHF))
.ToList();
layout = new Plotly.Blazor.Layout
{
};
data = new List<ITrace> { ChartTheme.BuildDonutTrace(ChartFormatting.FoldTail(totals)) };
var accounts = viewService.GetAccountViewList().Where(a => a.Class == ClassIds.Activa);
var values = new List<object>();
var labels = new List<object>();
var ids = new List<object>();
foreach (var account in accounts)
{
if (account.BalanceCHF > 0)
{
values.Add(account.BalanceCHF);
labels.Add(account.Name);
ids.Add(account.Id);
}
}
await base.OnInitializedAsync();
data = new List<ITrace>
{
new Pie
{
Values = values,
Labels = labels,
Ids = ids
}
};
return base.OnInitializedAsync();
}
}

View file

@ -1,10 +0,0 @@
@if (config is not null && layout is not null && data is not null)
{
<FluentCard Style="padding: 16px;">
<PlotlyChart style="height: 400px; min-height: 300px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
</FluentCard>
}
else
{
<Loading/>
}

View file

@ -1,29 +0,0 @@
using Microsoft.AspNetCore.Components;
using Plotly.Blazor;
using Plotly.Blazor.Traces;
using Schaad.Accounting.Interfaces;
namespace Schaad.Accounting.UI.Components.Pages.Charts;
public partial class BookingTextBreakdown : ComponentBase
{
private PlotlyChart chart = null!;
private Config? config;
private Plotly.Blazor.Layout? layout;
private IList<ITrace>? data;
[Parameter, EditorRequired]
public string AccountId { get; set; } = null!;
[Inject]
private IChartService chartService { get; set; } = null!;
protected override async Task OnInitializedAsync()
{
config = ChartTheme.BaseConfig();
layout = ChartTheme.BaseLayout("Nach Buchungstext");
var totals = await chartService.GetBookingTextTotalsAsync(AccountId);
data = new List<ITrace> { ChartTheme.BuildDonutTrace(ChartFormatting.FoldTail(totals)) };
}
}

View file

@ -1,10 +0,0 @@
@if (config is not null && layout is not null)
{
<FluentCard Style="padding: 16px;">
<PlotlyChart style="height: 400px; min-height: 300px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
</FluentCard>
}
else
{
<Loading/>
}

View file

@ -1,55 +0,0 @@
using Microsoft.AspNetCore.Components;
using Plotly.Blazor;
using Plotly.Blazor.LayoutLib;
using Plotly.Blazor.Traces;
using Schaad.Accounting.Interfaces;
namespace Schaad.Accounting.UI.Components.Pages.Charts;
public partial class BookingTextOverTime : ComponentBase
{
private PlotlyChart chart = null!;
private Config? config;
private Plotly.Blazor.Layout? layout;
private IList<ITrace> data = new List<ITrace>();
[Parameter, EditorRequired]
public string AccountId { get; set; } = null!;
[Inject]
private IChartService chartService { get; set; } = null!;
protected override async Task OnInitializedAsync()
{
config = ChartTheme.BaseConfig();
layout = ChartTheme.BaseLayout("Nach Buchungstext im Verlauf");
layout.BarMode = BarModeEnum.Stack;
layout.BarCornerRadius = 4;
layout.HoverMode = HoverModeEnum.XUnified;
layout.XAxis = new List<XAxis>
{
new()
{
Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Category,
GridColor = ChartTheme.GridColor,
LineColor = ChartTheme.GridColor
}
};
layout.YAxis = new List<YAxis>
{
new()
{
Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = "CHF" },
GridColor = ChartTheme.GridColor,
ZeroLineColor = ChartTheme.GridColor,
SeparateThousands = true
}
};
var dataSeries = await chartService.GetBookingTextExpensesPerMonthAsync(AccountId);
data = ChartTheme.BuildStackedBarTraces(ChartFormatting.FoldTail(dataSeries));
await base.OnInitializedAsync();
}
}

View file

@ -1,72 +0,0 @@
using Schaad.Accounting;
using Schaad.Accounting.Datasets.Charts;
namespace Schaad.Accounting.UI.Components.Pages.Charts;
/// <summary>
/// Swiss (de-CH) formatting for chart labels/tooltips, plus the top-N + "Andere"
/// folding used to keep pies and stacked series legible. Plotly's own d3-format
/// tokens can't produce the apostrophe thousands separator, so values are
/// pre-formatted here in C# and passed to Plotly as literal text.
/// </summary>
public static class ChartFormatting
{
private static readonly string[] MonthAbbreviations =
[
"Jan", "Feb", "Mrz", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"
];
public static string MonthLabel(DateOnly date) => $"{MonthAbbreviations[date.Month - 1]} {date.Year % 100:00}";
public static string Chf(decimal value) => $"{value.ToFormattedString()} CHF";
/// <summary>
/// Sorts by magnitude descending and, past <see cref="ChartTheme.MaxCategories"/>,
/// collapses the remainder into a single "Andere" entry so a pie/legend never
/// has to carry more slices than the categorical palette can distinguish.
/// </summary>
public static IReadOnlyList<CategoryTotal> FoldTail(IReadOnlyList<CategoryTotal> items)
{
var sorted = items.OrderByDescending(i => Math.Abs(i.Value)).ToList();
if (sorted.Count <= ChartTheme.MaxCategories)
{
return sorted;
}
var head = sorted.Take(ChartTheme.MaxCategories - 1).ToList();
var tailTotal = sorted.Skip(ChartTheme.MaxCategories - 1).Sum(i => i.Value);
head.Add(new CategoryTotal(ChartTheme.OtherLabel, tailTotal));
return head;
}
/// <summary>
/// Same idea as <see cref="FoldTail(IReadOnlyList{CategoryTotal})"/> for a set of
/// monthly series (e.g. a stacked bar chart): series are ranked by total magnitude
/// across all months, and everything past the cap is summed month-by-month into
/// a single "Andere" series.
/// </summary>
public static IReadOnlyList<DataSerie> FoldTail(IReadOnlyList<DataSerie> series)
{
if (series.Count <= ChartTheme.MaxCategories)
{
return series;
}
var ranked = series.OrderByDescending(s => s.Y.Sum(Math.Abs)).ToList();
var head = ranked.Take(ChartTheme.MaxCategories - 1).ToList();
var tail = ranked.Skip(ChartTheme.MaxCategories - 1).ToList();
var x = tail[0].X;
var otherY = new decimal[x.Count];
foreach (var serie in tail)
{
for (var i = 0; i < x.Count; i++)
{
otherY[i] += serie.Y[i];
}
}
head.Add(new DataSerie(ChartTheme.OtherLabel, ChartTheme.OtherLabel, x, otherY));
return head;
}
}

View file

@ -1,144 +0,0 @@
using Plotly.Blazor;
using Plotly.Blazor.LayoutLib;
using Plotly.Blazor.Traces;
using Schaad.Accounting.Datasets.Charts;
namespace Schaad.Accounting.UI.Components.Pages.Charts;
/// <summary>
/// Shared look for every chart: a fixed-order, colour-vision-safe categorical
/// palette plus Fluent-matching chrome. The palette is the validated default
/// from the project's data-viz guidelines (scripts/validate_palette.js) - do
/// not add or reorder hues without re-running the validator.
/// </summary>
public static class ChartTheme
{
public static readonly string[] Categorical =
[
"#2a78d6", // blue
"#eb6834", // orange
"#1baf7a", // aqua
"#eda100", // yellow
"#e87ba4", // magenta
"#008300", // green
"#4a3aa7", // violet
"#e34948" // red
];
/// <summary>Beyond this many categories, fold the tail into a single "Andere" bucket.</summary>
public const int MaxCategories = 7;
public const string OtherLabel = "Andere";
// Muted ink, not a series hue - "Andere" must never look like a real category.
public const string OtherColor = "#898781";
public const string GridColor = "#e1e0d9";
public const string PrimaryInk = "#0b0b0b";
public const string MutedInk = "#52514e";
public const string PlotSurface = "#fcfcfb";
public const string FontFamily = "'Helvetica Neue', Helvetica, Arial, sans-serif";
public static string ColorAt(int index) => index < Categorical.Length ? Categorical[index] : OtherColor;
public static Config BaseConfig() => new()
{
Responsive = true,
DisplayLogo = false
};
public static Plotly.Blazor.Layout BaseLayout(string? title = null)
{
var layout = new Plotly.Blazor.Layout
{
Font = new Font { Family = FontFamily, Color = PrimaryInk, Size = 12 },
PaperBgColor = PlotSurface,
PlotBgColor = PlotSurface,
Margin = new Margin { L = 48, R = 24, T = string.IsNullOrEmpty(title) ? 16 : 48, B = 40, Pad = 4 },
Legend = new List<Legend>
{
new()
{
Font = new() { Family = FontFamily, Color = MutedInk, Size = 12 },
BgColor = "rgba(0,0,0,0)"
}
},
HoverLabel = new()
{
BgColor = "#ffffff",
BorderColor = GridColor,
Font = new() { Family = FontFamily, Color = PrimaryInk, Size = 12 }
}
};
if (!string.IsNullOrEmpty(title))
{
layout.Title = new Title { Text = title, Font = new() { Family = FontFamily, Color = PrimaryInk, Size = 15 } };
}
return layout;
}
/// <summary>
/// A donut trace for a set of already-folded (<see cref="ChartFormatting.FoldTail(IReadOnlyList{CategoryTotal})"/>)
/// categories: fixed-order palette, CH-formatted hover text, and direct labels
/// outside the slice (values are magnitudes - a slice can't represent a sign).
/// </summary>
public static Pie BuildDonutTrace(IReadOnlyList<CategoryTotal> items)
{
var labels = new List<object>();
var values = new List<object>();
var text = new List<object>();
var colors = new List<object>();
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
labels.Add(item.Name);
values.Add(Math.Abs(item.Value));
text.Add(ChartFormatting.Chf(item.Value));
colors.Add(item.Name == OtherLabel ? OtherColor : ColorAt(i));
}
return new Pie
{
Labels = labels,
Values = values,
Text = text,
Sort = false,
Hole = 0.55m,
Marker = new() { Colors = colors, Line = new() { Color = PlotSurface, Width = 2 } },
TextInfo = Plotly.Blazor.Traces.PieLib.TextInfoFlag.Label,
TextPosition = Plotly.Blazor.Traces.PieLib.TextPositionEnum.Outside,
OutsideTextFont = new() { Family = FontFamily, Color = MutedInk, Size = 12 },
HoverTemplate = "<b>%{label}</b><br>%{text}<extra></extra>"
};
}
/// <summary>
/// One stacked-bar trace per already-folded (<see cref="ChartFormatting.FoldTail(IReadOnlyList{DataSerie})"/>)
/// series: fixed-order palette, a hairline surface gap between stacked segments,
/// CH month labels on the x-axis, and CH-formatted hover text.
/// </summary>
public static List<ITrace> BuildStackedBarTraces(IReadOnlyList<DataSerie> series)
{
var traces = new List<ITrace>();
for (var i = 0; i < series.Count; i++)
{
var serie = series[i];
var color = serie.Name == OtherLabel ? OtherColor : ColorAt(i);
traces.Add(new Bar
{
Name = serie.Name,
X = serie.X.Select(d => (object)ChartFormatting.MonthLabel(d)).ToList(),
Y = serie.Y.Select(v => (object)v).ToList(),
TextArray = serie.Y.Select(ChartFormatting.Chf).ToList(),
Marker = new() { Color = color, Line = new() { Color = PlotSurface, Width = 2 } },
HoverTemplate = "%{fullData.name}: %{text}<extra></extra>"
});
}
return traces;
}
}

View file

@ -1,15 +1,6 @@
@page "/spendings"
@page "/spendings"
@rendermode InteractiveServer
<PageTitle>Ausgaben</PageTitle>
<FluentCard Style="padding: 16px; max-width: 900px;">
@if (config is not null && layout is not null && data is not null)
{
<PlotlyChart style="height: 560px; min-height: 420px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
}
else
{
<Loading/>
}
</FluentCard>
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />

View file

@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components;
using Plotly.Blazor;
using Plotly.Blazor.Traces;
using Schaad.Accounting.Datasets.Charts;
using Schaad.Accounting.Interfaces;
namespace Schaad.Accounting.UI.Components.Pages.Charts;
@ -9,27 +8,45 @@ namespace Schaad.Accounting.UI.Components.Pages.Charts;
public partial class Spendings : ComponentBase
{
private PlotlyChart chart = null!;
private Config? config;
private Plotly.Blazor.Layout? layout;
private IList<ITrace>? data;
private Config config = null!;
private Plotly.Blazor.Layout layout = null!;
private IList<ITrace> data = null!;
[Inject]
private IViewService viewService { get; set; } = null!;
protected override async Task OnInitializedAsync()
protected override Task OnInitializedAsync()
{
config = ChartTheme.BaseConfig();
layout = ChartTheme.BaseLayout("Ausgaben nach Kategorie");
config = new Config
{
Responsive = true
};
var accounts = (await viewService.GetAccountViewListAsync()).Where(a => a.Class == ClassIds.Expenses);
var totals = accounts
.GroupBy(a => a.SubClass)
.Select(g => new CategoryTotal(g.First().SubClassName, g.Sum(a => a.Balance)))
.Where(t => t.Value != 0)
.ToList();
layout = new Plotly.Blazor.Layout
{
};
data = new List<ITrace> { ChartTheme.BuildDonutTrace(ChartFormatting.FoldTail(totals)) };
var accounts = viewService.GetAccountViewList().Where(a => a.Class == ClassIds.Expenses);
var values = 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()}))
{
if (grp.List.Sum(g => g.Balance) != 0)
{
values.Add(grp.List.Sum(g => g.Balance));
labels.Add(grp.List.First().SubClassName);
}
}
await base.OnInitializedAsync();
data = new List<ITrace>
{
new Pie
{
Values =values,
Labels = labels
}
};
return base.OnInitializedAsync();
}
}

View file

@ -1,15 +1,6 @@
@page "/SpendingsOverTime"
@page "/SpendingsOverTime"
@rendermode InteractiveServer
<PageTitle>Ausgaben im Verlauf</PageTitle>
<FluentCard Style="padding: 16px;">
@if (config is not null && layout is not null)
{
<PlotlyChart style="height: 560px; min-height: 420px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
}
else
{
<Loading/>
}
</FluentCard>
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components;
using Plotly.Blazor;
using Plotly.Blazor.LayoutLib;
using Plotly.Blazor.Traces;
@ -9,44 +9,52 @@ namespace Schaad.Accounting.UI.Components.Pages.Charts;
public partial class SpendingsOverTime : ComponentBase
{
private PlotlyChart chart = null!;
private Config? config;
private Plotly.Blazor.Layout? layout;
private Config config = null!;
private Plotly.Blazor.Layout layout = null!;
private IList<ITrace> data = new List<ITrace>();
[Inject]
private IChartService chartService { get; set; } = null!;
protected override async Task OnInitializedAsync()
protected override Task OnInitializedAsync()
{
config = ChartTheme.BaseConfig();
config = new Config
{
Responsive = true
};
layout = ChartTheme.BaseLayout("Ausgaben im Verlauf");
layout.BarMode = BarModeEnum.Stack;
layout.BarCornerRadius = 4;
layout.HoverMode = HoverModeEnum.XUnified;
layout.XAxis = new List<XAxis>
layout = new Plotly.Blazor.Layout
{
Title = new Title { Text = "Ausgaben im Verlauf" },
BarMode = BarModeEnum.Stack,
YAxis = new List<YAxis>
{
new()
{
Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Category,
GridColor = ChartTheme.GridColor,
LineColor = ChartTheme.GridColor
Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = "CHF" }
}
};
layout.YAxis = new List<YAxis>
},
XAxis = new List<XAxis>
{
new()
{
Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = "CHF" },
GridColor = ChartTheme.GridColor,
ZeroLineColor = ChartTheme.GridColor,
SeparateThousands = true
Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Date
}
}
};
var dataSeries = await chartService.GetExpensesPerMonthAsync();
data = ChartTheme.BuildStackedBarTraces(ChartFormatting.FoldTail(dataSeries));
await base.OnInitializedAsync();
var dataSeries = chartService.GetExpensesPerMonth();
foreach (var dataSerie in dataSeries)
{
data.Add(new Bar
{
Name = dataSerie.Name,
X = dataSerie.X.Select(d => (object)d).ToList(),
Y = dataSerie.Y.Select(d => (object)d).ToList(),
});
};
return base.OnInitializedAsync();
}
}

View file

@ -10,10 +10,10 @@
<PropertyColumn Property="@(p => p.Number)" Sortable="true" IsDefaultSortColumn="true" Width="200px" />
<PropertyColumn Property="@(p => p.Name)" Sortable="true" />
<TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" title="Löschen" aria-label="Löschen" OnClick="@(() => DeleteAsync(context.Id))" />
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" OnClick="@(() => DeleteAsync(context.Id))" />
</TemplateColumn>
<TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" title="Bearbeiten" aria-label="Bearbeiten" OnClick="@(() => EditAsync(context.Id))" />
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" OnClick="@(() => EditAsync(context.Id))" />
</TemplateColumn>
</FluentDataGrid>

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
@ -14,15 +14,12 @@ public partial class Classes : ComponentBase
[Inject]
private IDialogService dialogService { get; set; } = null!;
[Inject]
private IToastService toastService { get; set; } = null!;
private IQueryable<SubClass>? subclassQueryable;
protected override async Task OnInitializedAsync()
protected override Task OnInitializedAsync()
{
subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
await base.OnInitializedAsync();
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable();
return base.OnInitializedAsync();
}
private async Task AddAsync()
@ -39,14 +36,13 @@ public partial class Classes : ComponentBase
var result = await dialog.Result;
if (!result.Cancelled && result.Data != null)
{
subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
toastService.ShowToast(ToastIntent.Success, "Klasse gespeichert", 3000);
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable();
}
}
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()
{
Height = "500px",
@ -58,21 +54,19 @@ public partial class Classes : ComponentBase
var result = await dialog.Result;
if (!result.Cancelled && result.Data != null)
{
subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
toastService.ShowToast(ToastIntent.Success, "Klasse gespeichert", 3000);
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable();
}
}
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 result = await dialog.Result;
if (!result.Cancelled)
{
await subclassRepository.DeleteSubClassAsync(id);
subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
toastService.ShowToast(ToastIntent.Success, "Klasse gelöscht", 3000);
subclassRepository.DeleteSubClass(id);
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable();
}
}
}

View file

@ -14,11 +14,7 @@ public partial class AccountSelector : ComponentBase
private Account? SelectedAccount { get; set; }
// OnParametersSet, not OnInitialized: OnInitialized fires once, but callers now
// pass an empty Accounts list on first render and hydrate it after an await.
// OnParametersSet re-runs the lookup whenever Accounts or AccountId change, so the
// preselection tracks late-arriving data.
protected override void OnParametersSet()
protected override void OnInitialized()
{
SelectedAccount = Accounts.FirstOrDefault(account => account.Id == AccountId);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
@implements IDialogContentComponent<Schaad.Accounting.Models.Transaction>
@using Schaad.Accounting.Models
@implements IDialogContentComponent<Schaad.Accounting.Models.Transaction>
@rendermode InteractiveServer
<FluentDialogHeader ShowDismiss="true">
@ -11,12 +12,6 @@
</FluentDialogHeader>
<FluentDialogBody>
@if (editContext is null)
{
<Loading/>
return;
}
<EditForm EditContext="@editContext">
<DataAnnotationsValidator />
<FluentDatePicker @bind-Value="@SelectedValue" Label="Valuta" Style="width: 300px;"/>
@ -42,7 +37,6 @@
@bind-Value="@Content.TargetAccountId" />
<FluentNumberField @bind-Value="@Content.Value" Label="Betrag" Style="width: 300px;"/>
<FluentTextField @bind-Value="@Content.Text" Label="Buchungstext" Style="width: 300px;" />
<FluentNumberField @bind-Value="@Content.FxRate" Label="Kurs" Style="width: 100px;" />
<div>
<FluentValidationSummary />
@ -56,7 +50,6 @@
Abbrechen
</FluentButton>
<FluentButton Appearance="Appearance.Accent"
Disabled="@(SelectedValue is null)"
OnClick="@SaveAsync">
Speichern
</FluentButton>

View file

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

View file

@ -11,12 +11,6 @@
</FluentDialogHeader>
<FluentDialogBody>
@if (editContext is null)
{
<Loading/>
return;
}
Total: @Math.Abs(Content.Value) / Rest: @openAmount
<FluentButton Appearance="Appearance.Neutral" IconEnd="@(new Icons.Regular.Size24.Add())"
OnClick="@AddTransactionAsync">
@ -65,7 +59,6 @@
</td>
<td style="align-content: center">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral"
title="Zeile entfernen" aria-label="Zeile entfernen"
OnClick="@(() => transactionList.Remove(transaction))"/>
</td>
</tr>
@ -74,15 +67,6 @@
</table>
<div>
<FluentValidationSummary/>
@if (validationErrors.Count > 0)
{
<ul class="validation-message">
@foreach (var err in validationErrors)
{
<li>@err</li>
}
</ul>
}
</div>
</EditForm>
</FluentDialogBody>

View file

@ -9,7 +9,7 @@ namespace Schaad.Accounting.UI.Components.Pages.Dialogs;
public partial class TransactionSplitDialog : ComponentBase
{
private EditContext? editContext;
private EditContext editContext = null!;
[CascadingParameter]
public FluentDialog Dialog { get; set; } = null!;
@ -33,18 +33,17 @@ public partial class TransactionSplitDialog : ComponentBase
private IViewService viewService { get; set; } = null!;
private List<Transaction> transactionList = new();
private IReadOnlyList<AccountDataset> accounts = [];
private IReadOnlyList<AccountDataset> accounts = null!;
private IReadOnlyList<string> bookingTexts = [];
private decimal openAmount = 0;
private List<string> validationErrors = new();
protected override async Task OnInitializedAsync()
protected override void OnInitialized()
{
accounts = await viewService.GetAccountViewListAsync();
bookingTexts = (await bookingTextRepository.GetBookingTextListAsync()).Select(b => b.Text).ToArray();
var accountList = await accountRepository.GetAccountListAsync();
var bankTrx = await bankTransactionRepository.GetBankTransactionAsync(Content.BankTransactionId);
accounts = viewService.GetAccountViewList();
bookingTexts = bookingTextRepository.GetBookingTextList().Select(b => b.Text).ToArray();
var accountList = accountRepository.GetAccountList();
var bankTrx = bankTransactionRepository.GetBankTransaction(Content.BankTransactionId);
var trx = new Transaction(bankTrx, accountList);
trx.Value = Math.Abs(bankTrx.Value);
transactionList.Add(trx);
@ -69,44 +68,17 @@ public partial class TransactionSplitDialog : ComponentBase
openAmount = Content.Value + transactionList.Sum(t => t.Value);
}
// Every row in transactionList needs Text, TargetAccountId, and a non-zero Value.
// The single EditContext above only covers the first row (that's what it was
// constructed with) — so DataAnnotations validation misses subsequent split rows.
// Run explicit per-row checks here and surface everything in validationErrors.
private bool ValidateAllRows()
{
validationErrors.Clear();
for (var i = 0; i < transactionList.Count; i++)
{
var t = transactionList[i];
if (string.IsNullOrWhiteSpace(t.Text))
{
validationErrors.Add($"Zeile {i + 1}: Buchungstext fehlt");
}
if (string.IsNullOrEmpty(t.TargetAccountId))
{
validationErrors.Add($"Zeile {i + 1}: Konto fehlt");
}
if (t.Value == 0)
{
validationErrors.Add($"Zeile {i + 1}: Betrag darf nicht 0 sein");
}
}
return validationErrors.Count == 0;
}
private async Task SaveAsync()
{
if (!ValidateAllRows())
if (editContext.Validate())
{
return;
}
foreach (var transtaction in transactionList)
{
await transactionRepository.SaveTransactionAsync(transtaction);
transactionRepository.SaveTransaction(transtaction);
}
await Dialog.CloseAsync(Content);
}
}
private async Task CancelAsync()
{

View file

@ -1,9 +1,102 @@
@page "/"
@page "/"
@rendermode InteractiveServer
<PageTitle>Home</PageTitle>
<StatementImport OnImported="RefreshPendingAsync"/>
<FluentInputFile @ref="@myFileByBuffer"
AnchorId="MyUploadBuffer"
DragDropZoneVisible="false"
Mode="InputFileMode.Buffer"
Multiple="true"
MaximumFileSize="@(10 * 1024 * 1024)"
Accept=".zip, .xml"
OnProgressChange="@OnProgressChangeAsync"
OnCompleted="@OnCompletedAsync"/>
<PendingBookings @ref="pendingBookings"/>
<FluentProgress Visible="@(progressPercent > 0)" Min="0" Max="100" Value="@progressPercent"/>
<FluentLabel Alignment="HorizontalAlignment.Center">
@progressTitle
</FluentLabel>
<FluentButton Appearance="Appearance.Lightweight" IconStart="@(new Icons.Regular.Size16.ArrowUpload())"
Id="MyUploadBuffer">
Upload files
</FluentButton>
<FluentButton Appearance="Appearance.Lightweight" IconStart="@(new Icons.Regular.Size16.Dismiss())"
@onclick="@((e) => IsCanceled = true)">
Cancel
</FluentButton>
@if (matchingBankTransactions == null || matchingBankTransactions.Count == 0)
{
<p>Keine Transaktionen vorhanden!</p>
<FluentEmoji Value="@(new Emojis.SmileysEmotion.Color.Default.MoneyMouthFace())" />
}
else
{
<table>
<thead>
<tr>
<th>Buchungsdatum</th>
<th>Valutadatum</th>
<th>Zusatzinfo</th>
<th>Text</th>
<th>Konto</th>
<th>Betrag</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var transaction in matchingBankTransactions)
{
<tr>
<td>
<div>@transaction.BookingDate.ToString("dd.MM.yyyy")</div>
</td>
<td>
<div>@transaction.ValueDate.ToString("dd.MM.yyyy")</div>
</td>
<td class="zusatzinfo-column" title="@transaction.BankTransactionText">
<div>@transaction.BankTransactionText</div>
</td>
<td>
<div class="text-column">
<input list="texts" @bind="@transaction.Text" placeholder="Type or select..." readonly="@transaction.ReadOnly"/>
<datalist id="texts">
@foreach (var text in bookingTexts.OrderBy(t => t))
{
<option value="@text"></option>
}
</datalist>
</div>
</td>
<td>
@if (transaction.Value > 0)
{
<AccountSelector Accounts="accounts" @bind-AccountId="@transaction.OriginAccountId" />
}
else
{
<AccountSelector Accounts="accounts" @bind-AccountId="@transaction.TargetAccountId" />
}
</td>
<td>
<div>@Math.Abs(transaction.Value).ToFormattedString()</div>
</td>
<td style="align-content: center">
<FluentButton IconEnd="@(new Icons.Regular.Size16.SplitVertical())" Appearance="Appearance.Neutral"
OnClick="@(() => SplitBankTransactionAsync(transaction))" Disabled="@(transaction.Value > 0 || transaction.ReadOnly)" />
</td>
<td style="align-content: center">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Save())" Appearance="Appearance.Neutral"
OnClick="@(() => BookBankTransactionAsync(transaction))"
Disabled="@transaction.ReadOnly" />
</td>
</tr>
}
</tbody>
</table>
}

View file

@ -1,16 +1,197 @@
using System.IO.Compression;
using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.UI.Components.Pages.Dialogs;
namespace Schaad.Accounting.UI.Components.Pages;
public partial class Home : ComponentBase
{
private PendingBookings? pendingBookings;
[Inject]
private IViewService viewService { get; set; } = null!;
private async Task RefreshPendingAsync()
[Inject]
private IToastService toastService { get; set; } = null!;
[Inject]
private IMessageService messageService { get; set; } = null!;
[Inject]
private IDialogService dialogService { get; set; } = null!;
[Inject]
private ISettingsService settingsService { get; set; } = null!;
[Inject]
private IFileService fileService { get; set; } = null!;
[Inject]
private IBookingTextRepository bookingTextRepository { get; set; } = null!;
[Inject]
private ITransactionRepository transactionRepository { get; set; } = null!;
[Inject]
private NavigationManager Navigation { get; set; } = null!;
private List<Transaction>? matchingBankTransactions;
private IReadOnlyList<Account> accounts = [];
private IReadOnlyList<string> bookingTexts = [];
FluentInputFile? myFileByBuffer;
int? progressPercent;
string? progressTitle;
bool IsCanceled;
Dictionary<int, string> Files = new();
private bool loaded;
protected override Task OnInitializedAsync()
{
if (pendingBookings is not null)
{
await pendingBookings.RefreshAsync();
if (loaded == false) {
matchingBankTransactions = viewService.MatchOpenBankTransactions();
accounts = viewService.GetAccountViewList();
bookingTexts = bookingTextRepository.GetBookingTextList().Select(b => b.Text).ToArray();
loaded = true;
}
return base.OnInitializedAsync();
}
async Task OnProgressChangeAsync(FluentInputFileEventArgs file)
{
progressPercent = file.ProgressPercent;
progressTitle = file.ProgressTitle;
// To cancel?
file.IsCancelled = IsCanceled;
// New file
if (!Files.ContainsKey(file.Index))
{
var localFile = Path.GetTempFileName() + file.Name;
Files.Add(file.Index, localFile);
}
// Write to the FileStream
await file.Buffer.AppendToFileAsync(Files[file.Index]);
}
private async Task OnCompletedAsync(IEnumerable<FluentInputFileEventArgs> files)
{
progressPercent = myFileByBuffer!.ProgressPercent;
progressTitle = myFileByBuffer!.ProgressTitle;
foreach (var file in Files)
{
var extension = Path.GetExtension(file.Value);
if (extension is ".xml")
{
await ImportXmlAndShowResultAsync(file.Value);
}
else if (extension is ".zip")
{
var unzippedFiles = UnzipAndListFiles(file.Value);
foreach (var unzippedFile in unzippedFiles)
{
await ImportXmlAndShowResultAsync(unzippedFile);
File.Delete(unzippedFile);
}
}
File.Delete(file.Value);
}
matchingBankTransactions = viewService.MatchOpenBankTransactions();
}
private async Task ImportXmlAndShowResultAsync(string fileName)
{
var messages = fileService.ImportAccountStatementFile(fileName);
foreach (var message in messages)
{
await ShowImportResultAsync(message);
}
}
private IReadOnlyList<string> UnzipAndListFiles(string zipFilePath)
{
var tempExtractPath = Path.Combine(Path.GetTempPath(), Path.GetFileNameWithoutExtension(Path.GetRandomFileName()));
Directory.CreateDirectory(tempExtractPath);
ZipFile.ExtractToDirectory(zipFilePath, tempExtractPath);
return new List<string>(Directory.GetFiles(tempExtractPath, "*", SearchOption.AllDirectories));
}
private async Task ShowImportResultAsync(MessageDataset message)
{
var joinedDetails = string.Join(", ", message.Lines);
var text = $"{message.Title}: {joinedDetails}";
if (message.Status == MessageStatus.Success)
{
ShowToast(text, ToastIntent.Success);
}
else if (message.Status == MessageStatus.Info)
{
ShowToast(text, ToastIntent.Info);
}
else if (message.Status == MessageStatus.Warning)
{
await ShowMessageAsync(text, MessageIntent.Warning);
}
else if (message.Status == MessageStatus.Error)
{
await ShowMessageAsync(text, MessageIntent.Error);
}
}
private async Task ShowMessageAsync(string message, MessageIntent intent)
{
await messageService.ShowMessageBarAsync(message, intent, "MESSAGES_TOP");
}
private void ShowToast(string message, ToastIntent intent)
{
toastService.ShowToast(intent, message, 3000);
}
private async Task BookBankTransactionAsync(Transaction transaction)
{
if (string.IsNullOrEmpty(transaction.Text) || string.IsNullOrEmpty(transaction.TargetAccountId))
{
ShowToast("Transaktion kann wegen fehlenden Daten nicht gespeichert werden!", ToastIntent.Error);
return;
}
transactionRepository.SaveTransaction(transaction);
transaction.ReadOnly = true;
await Task.CompletedTask;
}
private async Task SplitBankTransactionAsync(Transaction transaction)
{
var dialog = await dialogService.ShowDialogAsync<TransactionSplitDialog>(transaction, new DialogParameters()
{
Height = "500px",
Width = "800px",
Title = "Split Transaktion",
PreventDismissOnOverlayClick = true,
PreventScroll = true,
});
var result = await dialog.Result;
if (!result.Cancelled && result.Data != null)
{
ReloadPage();
await Task.CompletedTask;
}
}
private void ReloadPage()
{
var uri = Navigation.Uri;
Navigation.NavigateTo(uri, forceLoad: true); // forceLoad = true does a full reload
}
}

View file

@ -1,75 +0,0 @@
@rendermode InteractiveServer
@if (matchingBankTransactions is null || matchingBankTransactions.Count == 0)
{
<p>Keine Transaktionen vorhanden!</p>
<FluentEmoji Value="@(new Emojis.SmileysEmotion.Color.Default.MoneyMouthFace())" />
}
else
{
<table>
<thead>
<tr>
<th>Buchungsdatum</th>
<th>Valutadatum</th>
<th>Zusatzinfo</th>
<th>Text</th>
<th>Konto</th>
<th>Betrag</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var transaction in matchingBankTransactions)
{
<tr>
<td>
<div>@transaction.BookingDate.ToString("dd.MM.yyyy")</div>
</td>
<td>
<div>@transaction.ValueDate.ToString("dd.MM.yyyy")</div>
</td>
<td class="zusatzinfo-column" title="@transaction.BankTransactionText">
<div>@transaction.BankTransactionText</div>
</td>
<td>
<div class="text-column">
<input list="texts" @bind="@transaction.Text" placeholder="Type or select..." readonly="@transaction.ReadOnly"/>
<datalist id="texts">
@foreach (var text in bookingTexts.OrderBy(t => t))
{
<option value="@text"></option>
}
</datalist>
</div>
</td>
<td>
@if (transaction.Value > 0)
{
<AccountSelector Accounts="accounts" @bind-AccountId="@transaction.OriginAccountId" />
}
else
{
<AccountSelector Accounts="accounts" @bind-AccountId="@transaction.TargetAccountId" />
}
</td>
<td>
<div>@Math.Abs(transaction.Value).ToFormattedString()</div>
</td>
<td style="align-content: center">
<FluentButton IconEnd="@(new Icons.Regular.Size16.SplitVertical())" Appearance="Appearance.Neutral"
title="Aufteilen" aria-label="Aufteilen"
OnClick="@(() => SplitBankTransactionAsync(transaction))" Disabled="@(transaction.Value > 0 || transaction.ReadOnly)" />
</td>
<td style="align-content: center">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Save())" Appearance="Appearance.Neutral"
title="Buchen" aria-label="Buchen"
OnClick="@(() => BookBankTransactionAsync(transaction))"
Disabled="@transaction.ReadOnly" />
</td>
</tr>
}
</tbody>
</table>
}

View file

@ -1,81 +0,0 @@
using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.UI.Components.Pages.Dialogs;
namespace Schaad.Accounting.UI.Components.Pages;
public partial class PendingBookings : ComponentBase
{
[Inject]
private IViewService viewService { get; set; } = null!;
[Inject]
private ITransactionRepository transactionRepository { get; set; } = null!;
[Inject]
private IBookingTextRepository bookingTextRepository { get; set; } = null!;
[Inject]
private IDialogService dialogService { get; set; } = null!;
[Inject]
private IToastService toastService { get; set; } = null!;
private List<Transaction>? matchingBankTransactions;
private IReadOnlyList<Account> accounts = [];
private IReadOnlyList<string> bookingTexts = [];
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
await base.OnInitializedAsync();
}
/// <summary>
/// Re-run the auto-matcher and reload accounts / booking texts. Called from
/// the parent (Home) after a statement import completes.
/// </summary>
public async Task RefreshAsync()
{
await LoadDataAsync();
StateHasChanged();
}
private async Task LoadDataAsync()
{
matchingBankTransactions = await viewService.MatchOpenBankTransactionsAsync();
accounts = await viewService.GetAccountViewListAsync();
bookingTexts = (await bookingTextRepository.GetBookingTextListAsync()).Select(b => b.Text).ToArray();
}
private async Task BookBankTransactionAsync(Transaction transaction)
{
if (string.IsNullOrEmpty(transaction.Text) || string.IsNullOrEmpty(transaction.TargetAccountId))
{
toastService.ShowToast(ToastIntent.Error, "Transaktion kann wegen fehlenden Daten nicht gespeichert werden!", 3000);
return;
}
await transactionRepository.SaveTransactionAsync(transaction);
transaction.ReadOnly = true;
}
private async Task SplitBankTransactionAsync(Transaction transaction)
{
var dialog = await dialogService.ShowDialogAsync<TransactionSplitDialog>(transaction, new DialogParameters()
{
Height = "500px",
Width = "800px",
Title = "Split Transaktion",
PreventDismissOnOverlayClick = true,
PreventScroll = true,
});
var result = await dialog.Result;
if (!result.Cancelled && result.Data != null)
{
await LoadDataAsync();
}
}
}

View file

@ -1,13 +1,10 @@
@page "/BalanceReport"
@page "/BalanceReport"
@if (balance is null)
{
<Loading/>
return;
}
<ReportHeader Title="@header"/>
<h3>@header</h3>
<a href="javascript:window.print()" class="hidePrint">Print</a>
<br />
<br />
<table class="report">
<thead>
<tr>
@ -17,11 +14,16 @@
<th class="right">Passiven</th>
</tr>
<tr>
<th colspan="4"><hr/></th>
<th colspan="4"><hr /></th>
</tr>
</thead>
<tbody>
<ReportSectionHeader Title="Aktiven" Colspan="4"/>
<tr>
<td colspan="4" class="header">Aktiven</td>
</tr>
<tr>
<td colspan="4"><hr /></td>
</tr>
@foreach (var account in balance.ActivaAccountList.OrderBy(m => m.Number))
{
@if (account.BalanceCHF == 0) { continue; }
@ -33,14 +35,23 @@
<td></td>
</tr>
}
<ReportDivider Colspan="4"/>
<tr>
<td colspan="4"><hr /></td>
</tr>
<tr>
<td colspan="2" class="total">Total Aktiven</td>
<td class="right total">CHF @balance.TotalActivaCHF.ToFormattedString()</td>
<td></td>
</tr>
<ReportDivider Colspan="4"/>
<ReportSectionHeader Title="Passiven" Colspan="4"/>
<tr>
<td colspan="4"><hr /></td>
</tr>
<tr>
<td colspan="4" class="header">Passiven</td>
</tr>
<tr>
<td colspan="4"><hr /></td>
</tr>
@foreach (var account in balance.PassivaAccountList.OrderBy(m => m.Number))
{
@if (account.BalanceCHF == 0) { continue; }
@ -52,13 +63,17 @@
<td class="right">@Math.Abs(account.BalanceCHF).ToFormattedString()</td>
</tr>
}
<ReportDivider Colspan="4"/>
<tr>
<td colspan="4"><hr /></td>
</tr>
<tr>
<td colspan="2" class="total">Total Passiven</td>
<td></td>
<td class="right total">@balance.TotalPassivaCHF.ToFormattedString()</td>
</tr>
<ReportDivider Colspan="4"/>
<tr>
<td colspan="4"><hr /></td>
</tr>
<tr>
<td colspan="2" class="total">Total</td>
<td class="right">@balance.TotalActivaCHF.ToFormattedString()</td>
@ -74,6 +89,11 @@
<td class="right total sum">CHF @balance.TotalActivaCHF.ToFormattedString()</td>
<td class="right total sum">CHF @((balance.TotalPassivaCHF + win).ToFormattedString())</td>
</tr>
<ReportFooter Footer="@footer" Colspan="4"/>
<tr>
<td colspan="4" style="height: 30px">&nbsp;</td>
</tr>
<tr>
<td colspan="4" class="footer">@footer</td>
</tr>
</tbody>
</table>

Some files were not shown because too many files have changed in this diff Show more