Compare commits
3 commits
main
...
dont-reloa
| Author | SHA1 | Date | |
|---|---|---|---|
| 7236434f8a | |||
| b46d0fe5f1 | |||
| 3d3a6af103 |
120 changed files with 1689 additions and 4263 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -41,9 +41,6 @@ ScaffoldingReadMe.txt
|
|||
*~
|
||||
CodeCoverage/
|
||||
|
||||
# JetBrains Rider / ReSharper user-specific settings
|
||||
*.DotSettings.user
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
92
CLAUDE.md
92
CLAUDE.md
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
354
MCP_DESIGN.md
354
MCP_DESIGN.md
|
|
@ -1,354 +0,0 @@
|
|||
# MCP server design
|
||||
|
||||
Design produced 2026-07-03 in conversation with Claude. Read alongside `IMPROVEMENT_PLAN.md`.
|
||||
|
||||
## Goals
|
||||
|
||||
- Let Claude answer questions like *"how much did I spend on groceries in Q1?"*, *"what's my current balance in CHF?"*, *"which recurring debits changed vs. last year?"*.
|
||||
- **Read-only.** No `Save*`, `Delete*`, `Backup`, or `Import*` tools ever exposed.
|
||||
- Reuse the async `ViewService` / repository stack already in the codebase. No parallel data layer.
|
||||
- Local stdio transport. No HTTP, no auth surface. The MCP server process runs on your machine, but any field a tool returns is forwarded by the MCP client to whichever model it's paired with. See [Privacy modes](#privacy-modes) below for what that actually means in practice.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Transport
|
||||
|
||||
- **Stdio JSON-RPC** via the official `ModelContextProtocol` .NET SDK (Microsoft org's MCP package). Console app, `Microsoft.Extensions.Hosting`-based, `[McpServerToolType]` + `[McpServerTool]` attributes.
|
||||
- No custom framing/serialization — the SDK handles it.
|
||||
|
||||
### Project layout
|
||||
|
||||
```
|
||||
Schaad.Accounting.Mcp/
|
||||
├── Schaad.Accounting.Mcp.csproj - net9.0 console
|
||||
├── Program.cs - host + DI + McpServer.CreateStdioServer
|
||||
├── Context/
|
||||
│ └── AccountingScope.cs - year/mandator switching (see below)
|
||||
├── Dtos/
|
||||
│ ├── AccountDto.cs - flat records for LLM consumption
|
||||
│ ├── TransactionDto.cs
|
||||
│ └── ...
|
||||
└── Tools/
|
||||
├── ContextTools.cs - list_mandators, list_years
|
||||
├── AccountTools.cs - list_accounts, get_account
|
||||
├── BalanceTools.cs - get_balance_summary, get_income_statement
|
||||
├── TransactionTools.cs - get_transactions, search_transactions
|
||||
└── AnalysisTools.cs - get_expenses_by_category, compare_years
|
||||
```
|
||||
|
||||
### DI wiring
|
||||
|
||||
The composition root `AddAccounting()` currently lives in `Schaad.Accounting.UI/Extensions.cs`. Two options:
|
||||
|
||||
1. **Extract** to a new tiny `Schaad.Accounting.Composition` project that references Db + Services. UI and Mcp both reference it.
|
||||
2. **Duplicate** the ~15-line registration block in `Mcp/Program.cs`.
|
||||
|
||||
Recommend **(1)** — with two composition roots, every subsequent DI change would otherwise need to touch both.
|
||||
|
||||
`Mcp/Program.cs` is then:
|
||||
|
||||
```csharp
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
builder.Services.Configure<SettingsDataset>(builder.Configuration.GetSection("Settings"));
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<SettingsDataset>>().Value);
|
||||
builder.Services.AddAccounting();
|
||||
|
||||
builder.Services
|
||||
.AddMcpServer()
|
||||
.WithStdioServerTransport()
|
||||
.WithToolsFromAssembly();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
```
|
||||
|
||||
## Handling year/mandator
|
||||
|
||||
`SettingsService` is `Singleton` and holds `year` + `mandator`. The UI mutates them via `MyHeader` + `forceLoad`. There is no equivalent in stdio MCP.
|
||||
|
||||
**Approach: every tool takes optional `year` and `mandator`; wrap execution in a scope that stashes & restores.**
|
||||
|
||||
```csharp
|
||||
public sealed class AccountingScope : IDisposable
|
||||
{
|
||||
private readonly ISettingsService settings;
|
||||
private readonly int oldYear;
|
||||
private readonly string oldMandator;
|
||||
|
||||
public AccountingScope(ISettingsService settings, int? year, string? mandator)
|
||||
{
|
||||
this.settings = settings;
|
||||
oldYear = settings.GetYear();
|
||||
oldMandator = settings.GetMandator();
|
||||
if (year.HasValue) settings.SetYear(year.Value);
|
||||
if (mandator is not null) settings.SetMandator(mandator);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
settings.SetYear(oldYear);
|
||||
settings.SetMandator(oldMandator);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Every tool wraps its body:
|
||||
|
||||
```csharp
|
||||
[McpServerTool, Description("...")]
|
||||
public async Task<...> ListAccountsAsync(int? year = null, string? mandator = null, ...)
|
||||
{
|
||||
using var _ = new AccountingScope(settingsService, year, mandator);
|
||||
// RepositoryCache is Scoped; resolve a fresh IServiceScope so scoped services
|
||||
// don't hold references to entries from an earlier (year, mandator).
|
||||
}
|
||||
```
|
||||
|
||||
**Concurrency**: stdio MCP calls are serial (one JSON-RPC message at a time), so the Singleton mutation window is safe. If we ever move to HTTP transport with concurrent calls, this design breaks — flag that in code.
|
||||
|
||||
**Cache freshness**: `RepositoryCache` is `Scoped` and keyed by absolute file path (which includes `dbPath` → year+mandator). When the scope changes year/mandator, the cache keys naturally change too, so stale data is not returned. But we should still resolve a fresh `IServiceScope` per tool call:
|
||||
|
||||
```csharp
|
||||
public class ToolExecutor
|
||||
{
|
||||
private readonly IServiceScopeFactory scopeFactory;
|
||||
private readonly ISettingsService settings;
|
||||
|
||||
public async Task<T> Run<T>(int? year, string? mandator, Func<IServiceProvider, Task<T>> body)
|
||||
{
|
||||
using var _ = new AccountingScope(settings, year, mandator);
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
return await body(scope.ServiceProvider);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each tool class takes `ToolExecutor` and wraps its body.
|
||||
|
||||
## Tool catalog
|
||||
|
||||
Naming: `snake_case`. All optional args default sensibly. All amounts are `decimal`, CHF-normalized where a currency isn't specified. All dates are ISO 8601 (`yyyy-MM-dd`).
|
||||
|
||||
### `list_mandators`
|
||||
|
||||
> List the mandators (client accounting sets) that have data.
|
||||
|
||||
- **Input:** `{ year?: int }`
|
||||
- **Output:** `{ mandators: string[] }`
|
||||
|
||||
### `list_years`
|
||||
|
||||
> List the years for which the given mandator has data.
|
||||
|
||||
- **Input:** `{ mandator?: string }`
|
||||
- **Output:** `{ years: int[] }`
|
||||
|
||||
### `list_accounts`
|
||||
|
||||
> List all accounts with their current-year balance in both account currency and CHF. Use before `get_transactions` to look up account IDs.
|
||||
|
||||
- **Input:** `{ year?, mandator?, class?: "activa"|"passiva"|"income"|"expenses" }`
|
||||
- **Output:**
|
||||
```json
|
||||
{
|
||||
"accounts": [
|
||||
{ "id": "...", "number": 1010, "name": "Checking", "currency": "CHF",
|
||||
"class": "activa", "subclassName": "Umlaufvermögen",
|
||||
"balance": 12345.67, "balanceChf": 12345.67 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `get_balance_summary`
|
||||
|
||||
> Total activa vs. total passiva, plus equity (activa − passiva). CHF.
|
||||
|
||||
- **Input:** `{ year?, mandator? }`
|
||||
- **Output:** `{ totalActivaChf, totalPassivaChf, equityChf }`
|
||||
|
||||
### `get_income_statement`
|
||||
|
||||
> Income statement (Erfolgsrechnung) for the year: profit, loss, net, and the account list per side.
|
||||
|
||||
- **Input:** `{ year?, mandator? }`
|
||||
- **Output:** `{ profitChf, lossChf, netChf, incomeAccounts: [...], expenseAccounts: [...] }`
|
||||
|
||||
### `get_transactions`
|
||||
|
||||
> Fetch transactions with filters. Prefer narrow filters — this is the main workhorse. Truncated at `limit` (default 200, max 1000).
|
||||
|
||||
- **Input:**
|
||||
```json
|
||||
{
|
||||
"year": 2026,
|
||||
"mandator": null,
|
||||
"accountId": null,
|
||||
"fromDate": "2026-01-01",
|
||||
"toDate": "2026-03-31",
|
||||
"textSearch": "coop",
|
||||
"minAmount": 100,
|
||||
"maxAmount": null,
|
||||
"limit": 200
|
||||
}
|
||||
```
|
||||
- **Output:**
|
||||
```json
|
||||
{
|
||||
"transactions": [
|
||||
{ "id": "...", "date": "2026-02-14", "bookingDate": "2026-02-14",
|
||||
"text": "Coop City", "value": 87.30, "currency": "CHF",
|
||||
"originAccountId": "chk", "originAccountName": "Checking",
|
||||
"targetAccountId": "groc", "targetAccountName": "Groceries",
|
||||
"relatedParty": "Coop Genossenschaft" }
|
||||
],
|
||||
"total": 47,
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
Backed by `ITransactionRepository.GetTransactionListAsync()` + in-memory filter — datasets are small enough that we don't need repo-level query pushdown.
|
||||
|
||||
### `get_expenses_by_category`
|
||||
|
||||
> Expenses grouped by subclass or account. Sorted descending by CHF total.
|
||||
|
||||
- **Input:** `{ year?, mandator?, groupBy?: "subclass" | "account" }`
|
||||
- **Output:** `{ categories: [{ name, totalChf, transactionCount }] }`
|
||||
|
||||
### `compare_years`
|
||||
|
||||
> Side-by-side comparison for equivalent categories across years. Useful for *"how did my spending change YoY"*.
|
||||
|
||||
- **Input:** `{ years: int[], mandator?, side: "income" | "expenses" | "both" }`
|
||||
- **Output:**
|
||||
```json
|
||||
{
|
||||
"comparison": [
|
||||
{ "year": 2025, "totalIncomeChf": ..., "totalExpensesChf": ...,
|
||||
"byCategory": { "Groceries": 4800.50, "Rent": 24000, ... } },
|
||||
{ "year": 2026, ... }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Output shape conventions
|
||||
|
||||
- **Flat records**: no nested navigation properties. Account name inlined next to id so the LLM doesn't need a follow-up call.
|
||||
- **CHF-normalized**: any total is always in CHF. Per-transaction values keep the account currency plus the CHF equivalent when accounts differ.
|
||||
- **No booleans for "successful"** — either the tool returns data or it errors. MCP has native error surfacing.
|
||||
- **Cap collection sizes**: `get_transactions` truncates at `limit`; the LLM decides whether to narrow the filter.
|
||||
|
||||
## Read-only enforcement
|
||||
|
||||
- Repository / service **interfaces** still expose write methods (they're used from the UI). What's exposed is controlled by simply not authoring a `[McpServerTool]` for those verbs.
|
||||
- To make this explicit, the `Tools/` folder contains **only read operations**, and typed errors (or similar) are the only way the LLM sees "no such account".
|
||||
- **Do NOT expose `SetYear` / `SetMandator` as tools.** Year/mandator switching happens implicitly per-call via `AccountingScope`. Otherwise the LLM could clobber the UI's session state.
|
||||
|
||||
## Packaging & Claude Desktop
|
||||
|
||||
Build once:
|
||||
|
||||
```bash
|
||||
dotnet publish Schaad.Accounting.Mcp/ -c Release -o D:/AccountingMcp
|
||||
```
|
||||
|
||||
Claude Desktop `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"schaad-accounting": {
|
||||
"command": "D:/AccountingMcp/Schaad.Accounting.Mcp.exe",
|
||||
"env": {
|
||||
"Settings__DataPath": "D:/Developer/AccountingData/",
|
||||
"Settings__DefaultMandator": "Claudio Schaad"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`FixerIoApiKey` doesn't need to be set — MCP tools only ever need CHF conversions of stored CHF values, and `DummyFxService` is the current implementation.
|
||||
|
||||
## Security
|
||||
|
||||
- **Local stdio only.** No listening port. Anything running as your user can already read the XML files; MCP doesn't widen the local-attack blast radius.
|
||||
- **`DataPath` only from environment / config.** Never accept `set_data_path` or similar. The LLM must not be able to point the server at arbitrary directories.
|
||||
- **No write tools** — repeated deliberately because it's the single biggest risk mitigation.
|
||||
- No secrets in tool outputs. The `FixerIoApiKey` is not returned by any tool.
|
||||
- Data exposure to the model provider is a separate axis from local security. See [Privacy modes](#privacy-modes) below.
|
||||
|
||||
## Privacy modes
|
||||
|
||||
**The data-flow reality.** When Claude Desktop calls an MCP tool, the invocation and result are relayed through the Anthropic API so the model can reason over them. "Local stdio" only means the *server process* runs on your PC — the JSON payload it emits still crosses the network on its way to the model. Other MCP clients (Cursor, Zed, any hosted-model client) work the same way.
|
||||
|
||||
To make the trade-off explicit, the server supports three privacy modes via a `Mcp:Privacy` config key.
|
||||
|
||||
### `Full` (default)
|
||||
|
||||
Every tool from the catalog is registered. Tool results include per-transaction detail: text, related party, amount, dates, source/target account names. Most useful for open-ended questions, most exposing — a single `get_transactions` call can send hundreds of rows of counterparty/amount data to the model provider.
|
||||
|
||||
**Use when:** you're comfortable with the model provider's data-use policy on your account, and you want maximum answer quality.
|
||||
|
||||
### `Aggregate`
|
||||
|
||||
Only aggregate tools are registered:
|
||||
|
||||
- `list_accounts` — trimmed: name, currency, class, `balanceChf`. **No account IDs, no bank account numbers, no start balance.**
|
||||
- `get_balance_summary`
|
||||
- `get_income_statement` — trimmed: totals per class, no per-account rows.
|
||||
- `get_expenses_by_category` — grouped by subclass name.
|
||||
- `compare_years` — same as `Full`.
|
||||
|
||||
Withheld: `get_transactions` (entirely), per-account balance rows, related-party names, individual dates and texts.
|
||||
|
||||
**Use when:** you want spending/income insight without exposing individual counterparties or transaction narratives. Trades power for privacy — Claude can answer *"how did my grocery spending change YoY?"* but not *"what was that odd 850 CHF withdrawal in March?"*.
|
||||
|
||||
### `Local`
|
||||
|
||||
Same tool catalog as `Full`, but intended to be paired with a **local** MCP client — e.g. an Ollama- or LM-Studio-backed client that runs the model on your own machine. The MCP server itself doesn't and can't enforce this; the choice happens in which client you point at the server binary. Nothing crosses the network. Answer quality is lower than Claude, sometimes considerably.
|
||||
|
||||
**Use when:** you don't want any financial data leaving your PC at all.
|
||||
|
||||
### Configuring the mode
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"schaad-accounting": {
|
||||
"command": "D:/AccountingMcp/Schaad.Accounting.Mcp.exe",
|
||||
"env": {
|
||||
"Settings__DataPath": "D:/Developer/AccountingData/",
|
||||
"Settings__DefaultMandator": "Claudio Schaad",
|
||||
"Mcp__Privacy": "Aggregate"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Program.cs` reads `Mcp:Privacy` at startup and decides which `[McpServerToolType]` classes to register with the SDK. Modes are enforced by *registration*, not by runtime filtering — a tool that's not registered simply doesn't exist from the model's point of view, so the model can't accidentally call it.
|
||||
|
||||
For belt-and-braces defence-in-depth, the sensitive-output DTOs (`TransactionDto`, per-account rows) can also live in a separate namespace whose types are only ever *constructed* by tools that are registered under `Full`; that way an accidentally shipped aggregate build won't compile against them.
|
||||
|
||||
## Out of scope for v1
|
||||
|
||||
- **Resources**: expose the chart of accounts as an MCP `Resource` so the LLM reads structure once instead of via `list_accounts` on every conversation.
|
||||
- **Prompts**: prebuilt Claude prompts (*"Monatlicher Kassenbericht"*, *"Erklär mir das Q3-Wachstum"*).
|
||||
- **Bank-transaction matching preview**: expose the `MatchOpenBankTransactions` output as a read-only tool so Claude can review the auto-matcher's guesses.
|
||||
- **Budgets / alerts**: not modelled in the app yet, so no data to expose.
|
||||
|
||||
## Rough implementation size
|
||||
|
||||
- New project + csproj + Program.cs: ~40 LOC
|
||||
- `AccountingScope` + `ToolExecutor`: ~40 LOC
|
||||
- 8 tools with DTOs: ~350 LOC (mostly declarative attribute schemas + `Select` projections)
|
||||
- Extract `AddAccounting()` to `Composition` project: ~30 LOC (mostly moving)
|
||||
- **Total: ~450 LOC**, plus config/docs.
|
||||
|
||||
## Suggested PR sequencing
|
||||
|
||||
- **PR Q** — Extract `AddAccounting()` from `Schaad.Accounting.UI/Extensions.cs` to a new `Schaad.Accounting.Composition` project referenced by UI and Mcp. Mechanical, low risk.
|
||||
- **PR R** — Add the `Schaad.Accounting.Mcp` project with `Program.cs`, `AccountingScope`, `ToolExecutor`, and the first two tools (`list_mandators`, `list_accounts`). Enough to wire Claude Desktop and iterate on the actual conversation feel before adding analytics tools.
|
||||
- **PR S** — Balance + income statement tools (`get_balance_summary`, `get_income_statement`).
|
||||
- **PR T** — `get_transactions` (the workhorse) with filter validation and truncation.
|
||||
- **PR U** — Analytics (`get_expenses_by_category`, `compare_years`).
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
namespace Schaad.Accounting
|
||||
{
|
||||
public static class ClassIds
|
||||
public class ClassIds
|
||||
{
|
||||
public const int Activa = 1;
|
||||
public const int Passiva = 2;
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
namespace Schaad.Accounting.Datasets.Charts
|
||||
{
|
||||
public record CategoryTotal(string Name, decimal Value);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
10
Schaad.Accounting.Common/Interfaces/IChartService.cs
Normal file
10
Schaad.Accounting.Common/Interfaces/IChartService.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
using System.Collections.Generic;
|
||||
using Schaad.Accounting.Datasets.Charts;
|
||||
|
||||
namespace Schaad.Accounting.Interfaces
|
||||
{
|
||||
public interface IChartService
|
||||
{
|
||||
IReadOnlyList<DataSerie> GetExpensesPerMonth();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
31
Schaad.Accounting.Common/Interfaces/IViewService.cs
Normal file
31
Schaad.Accounting.Common/Interfaces/IViewService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
{
|
||||
accounts[idx] = account;
|
||||
}
|
||||
else
|
||||
/// <summary>
|
||||
/// Save an account (insert/update)
|
||||
/// </summary>
|
||||
public void SaveAccount(Account account)
|
||||
{
|
||||
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(), file);
|
||||
if (File.Exists(lastYearFile))
|
||||
File.Copy(lastYearFile, filePath);
|
||||
|
||||
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), ACCOUNTS);
|
||||
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;
|
||||
foreach (var account in accounts.Where(a => a.Class == ClassIds.Activa))
|
||||
{
|
||||
if (account.LastBankBalance > 0)
|
||||
// set start balance to last bank balance
|
||||
if (file.IndexOf("Accounts") > -1)
|
||||
{
|
||||
account.StartBalance = account.LastBankBalance;
|
||||
modified = true;
|
||||
var accounts = GetAccountList();
|
||||
foreach (var account in accounts.Where(a => a.Class == ClassIds.Activa))
|
||||
{
|
||||
// bank accounts
|
||||
if (account.LastBankBalance > 0)
|
||||
{
|
||||
account.StartBalance = account.LastBankBalance;
|
||||
SaveAccount(account);
|
||||
}
|
||||
// cash accounts
|
||||
else if (account.Number < 1010)
|
||||
{
|
||||
account.StartBalance = 0;
|
||||
SaveAccount(account);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (account.Number < 1010)
|
||||
{
|
||||
account.StartBalance = 0;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
if (modified)
|
||||
{
|
||||
SaveAsync(accounts, ACCOUNTS).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,153 +1,72 @@
|
|||
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)
|
||||
{
|
||||
if (File.Exists(filePath) == false)
|
||||
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), fileName);
|
||||
if (File.Exists(lastYearFile))
|
||||
{
|
||||
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), fileName);
|
||||
if (File.Exists(lastYearFile))
|
||||
{
|
||||
File.Copy(lastYearFile, filePath);
|
||||
}
|
||||
File.Copy(lastYearFile, filePath);
|
||||
}
|
||||
}
|
||||
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)
|
||||
{
|
||||
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);
|
||||
return default(T);
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
else
|
||||
var bookingTexts = Load<List<BookingRule>>(BOOKING_RULES);
|
||||
return bookingTexts ?? new List<BookingRule>();
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
else
|
||||
var bookingTexts = Load<List<BookingText>>(BOOKING_TEXTS);
|
||||
return bookingTexts ?? new List<BookingText>();
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
else
|
||||
{
|
||||
subClass.Id = Guid.NewGuid().ToString();
|
||||
subclasses.Add(subClass);
|
||||
}
|
||||
await SaveAsync(subclasses, SUBCLASSES);
|
||||
var subclasses = Load<List<SubClass>>(SUBCLASSES);
|
||||
return subclasses ?? new List<SubClass>();
|
||||
}
|
||||
|
||||
public async Task<SubClass> GetSubClassAsync(string id)
|
||||
/// <summary>
|
||||
/// Save a booking text (insert/update)
|
||||
/// </summary>
|
||||
public void SaveSubClass(SubClass subClass)
|
||||
{
|
||||
var subclasses = await GetSubClassListAsync();
|
||||
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();
|
||||
}
|
||||
subClass.Copy(existingSubClass);
|
||||
Save(subclasses, SUBCLASSES);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get subclass
|
||||
/// </summary>
|
||||
public SubClass GetSubClass(string id)
|
||||
{
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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 result;
|
||||
return transaction;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
private async Task<List<DataSerie>> GetSubClassExpensesPerMonthAsync(List<TransactionDataset> expenseTransactions)
|
||||
{
|
||||
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 list = new List<DataSerie>();
|
||||
foreach (var grp in expenseTransactions.GroupBy(t => t.TargetAccount.SubClass))
|
||||
// More than one subclass xx
|
||||
if (transactions.Select(t => t.TargetAccount.SubClass).Distinct().Count() > 1)
|
||||
{
|
||||
var groupedByMonth = grp
|
||||
.GroupBy(t => t.ValueDate.Month)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(t => t.GetValue(t.TargetAccount.IsFxAccount)));
|
||||
return GetSubClassExpensesPerMonth();
|
||||
}
|
||||
|
||||
// only one subclass xx (z.B. Mandant Mannenbach)
|
||||
return GetAccountExpensesPerMonth();
|
||||
}
|
||||
|
||||
private List<DataSerie> GetSubClassExpensesPerMonth()
|
||||
{
|
||||
//var subclasses = subclassRepository.GetSubClassList();
|
||||
var transactions = viewService.GetTransactionViewList().Where(a => a.TargetAccount.Class == ClassIds.Expenses).ToList();
|
||||
var list = new List<DataSerie>();
|
||||
|
||||
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()}))
|
||||
{
|
||||
// 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],
|
||||
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()
|
||||
));
|
||||
//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>();
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
return null;
|
||||
}
|
||||
|
||||
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++)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
account: a,
|
||||
balance: balance,
|
||||
balanceCHF: fxConverter.ConvertToChf(balance, a.Currency),
|
||||
startBalanceCHF: fxConverter.ConvertToChf(a.StartBalance, a.Currency),
|
||||
className: subclassRepository.GetClass(a.Class),
|
||||
subClassName: subClassNameByNumber[a.SubClass]);
|
||||
})
|
||||
var accountList = accounts.Select(
|
||||
a =>
|
||||
new AccountDataset(
|
||||
account: a,
|
||||
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]
|
||||
)
|
||||
)
|
||||
.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));
|
||||
}
|
||||
if (debitsByAccount.TryGetValue(account.Id, out var debits))
|
||||
{
|
||||
balance -= debits.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;
|
||||
}
|
||||
|
||||
public BalanceSheetDataset GetBalanceSheetView(int year)
|
||||
{
|
||||
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 =>
|
||||
new TransactionDataset(
|
||||
t,
|
||||
accountsById[t.OriginAccountId],
|
||||
accountsById[t.TargetAccountId]))
|
||||
var transactionViewList = transactionList.Select(
|
||||
t =>
|
||||
new TransactionDataset(
|
||||
t,
|
||||
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 =>
|
||||
new TransactionDataset(
|
||||
t,
|
||||
accountsById[t.OriginAccountId],
|
||||
accountsById[t.TargetAccountId]))
|
||||
var transactionViewList = transactionList.Select(
|
||||
t =>
|
||||
new TransactionDataset(
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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("");
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
@Body
|
||||
</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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -8,30 +8,18 @@ namespace Schaad.Accounting.UI.Components.Pages;
|
|||
|
||||
public partial class Accounts : ComponentBase
|
||||
{
|
||||
[Inject]
|
||||
[Inject]
|
||||
private IAccountRepository accountRepository { get; set; } = null!;
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)" />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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/>
|
||||
}
|
||||
|
|
@ -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)) };
|
||||
}
|
||||
}
|
||||
|
|
@ -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/>
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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();
|
||||
|
||||
layout = ChartTheme.BaseLayout("Ausgaben im Verlauf");
|
||||
layout.BarMode = BarModeEnum.Stack;
|
||||
layout.BarCornerRadius = 4;
|
||||
layout.HoverMode = HoverModeEnum.XUnified;
|
||||
layout.XAxis = new List<XAxis>
|
||||
config = new Config
|
||||
{
|
||||
new()
|
||||
{
|
||||
Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Category,
|
||||
GridColor = ChartTheme.GridColor,
|
||||
LineColor = ChartTheme.GridColor
|
||||
}
|
||||
Responsive = true
|
||||
};
|
||||
layout.YAxis = new List<YAxis>
|
||||
|
||||
layout = new Plotly.Blazor.Layout
|
||||
{
|
||||
new()
|
||||
Title = new Title { Text = "Ausgaben im Verlauf" },
|
||||
BarMode = BarModeEnum.Stack,
|
||||
YAxis = new List<YAxis>
|
||||
{
|
||||
Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = "CHF" },
|
||||
GridColor = ChartTheme.GridColor,
|
||||
ZeroLineColor = ChartTheme.GridColor,
|
||||
SeparateThousands = true
|
||||
new()
|
||||
{
|
||||
Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = "CHF" }
|
||||
}
|
||||
},
|
||||
XAxis = new List<XAxis>
|
||||
{
|
||||
new()
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ public partial class AccountDialog : ComponentBase
|
|||
{
|
||||
if (editContext.Validate())
|
||||
{
|
||||
await accountRepository.SaveAccountAsync(Content);
|
||||
accountRepository.SaveAccount(Content);
|
||||
await Dialog.CloseAsync(Content);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;"/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ public partial class BookingTextDialog : ComponentBase
|
|||
{
|
||||
if (editContext.Validate())
|
||||
{
|
||||
await bookingTextRepository.SaveBookingTextAsync(Content);
|
||||
bookingTextRepository.SaveBookingText(Content);
|
||||
await Dialog.CloseAsync(Content);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ public partial class ClassDialog : ComponentBase
|
|||
{
|
||||
if (editContext.Validate())
|
||||
{
|
||||
await bookingRuleRepository.SaveSubClassAsync(Content);
|
||||
bookingRuleRepository.SaveSubClass(Content);
|
||||
await Dialog.CloseAsync(Content);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,43 +68,16 @@ 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)
|
||||
{
|
||||
transactionRepository.SaveTransaction(transtaction);
|
||||
}
|
||||
await Dialog.CloseAsync(Content);
|
||||
}
|
||||
foreach (var transtaction in transactionList)
|
||||
{
|
||||
await transactionRepository.SaveTransactionAsync(transtaction);
|
||||
}
|
||||
await Dialog.CloseAsync(Content);
|
||||
}
|
||||
|
||||
private async Task CancelAsync()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
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))
|
||||
{
|
||||
await pendingBookings.RefreshAsync();
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"> </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
Loading…
Reference in a new issue