Compare commits
43 commits
dont-reloa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fdecc9e67 | |||
| 4cc0888627 | |||
| 5d6d49ed8b | |||
| 7f284b1032 | |||
| a19bdf4558 | |||
| 90660177f2 | |||
| 1c5227ab9a | |||
| acc8b14c06 | |||
| 0432a2db75 | |||
| 2522274928 | |||
| ceef2c162f | |||
| 97bf85d6a8 | |||
| 0c48539ce6 | |||
| 766295a119 | |||
| 69d3122243 | |||
| 562338d0c4 | |||
| 58ca05f83e | |||
| 577d63c0fa | |||
| 93bfcae2a0 | |||
| 70f9ce0d58 | |||
| 60f01d437c | |||
| e6efdb5f3a | |||
| d64b3cd876 | |||
| f9084dc074 | |||
| e7ff097397 | |||
| 786fafefa5 | |||
| 1f1575b0af | |||
| 3ffe4578c2 | |||
| 857b312f9f | |||
| 3a770af016 | |||
| 3677b5cd6e | |||
| 7287dbd440 | |||
| 8926fb1e97 | |||
| 5d75bbb64d | |||
| 2eedd2fd20 | |||
| 1c7ba7f416 | |||
| 8961bb234c | |||
| 152d4c3d57 | |||
| 9f11f6490b | |||
|
|
a16aedcfd8 | ||
|
|
2c91ac7082 | ||
| 2b1eb0dbb7 | |||
|
|
37d568cbc2 |
121 changed files with 4269 additions and 1694 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -41,6 +41,9 @@ ScaffoldingReadMe.txt
|
||||||
*~
|
*~
|
||||||
CodeCoverage/
|
CodeCoverage/
|
||||||
|
|
||||||
|
# JetBrains Rider / ReSharper user-specific settings
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
# MSBuild Binary and Structured Log
|
# MSBuild Binary and Structured Log
|
||||||
*.binlog
|
*.binlog
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,28 +11,78 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Services"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Db", "Schaad.Accounting.Db\Schaad.Accounting.Db.csproj", "{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Db", "Schaad.Accounting.Db\Schaad.Accounting.Db.csproj", "{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Tests", "Schaad.Accounting.Tests\Schaad.Accounting.Tests.csproj", "{C0A48984-3770-49D7-9371-25351B4E79FF}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{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|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.ActiveCfg = Release|Any CPU
|
||||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|
|
||||||
92
CLAUDE.md
Normal file
92
CLAUDE.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# 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.
|
||||||
114
IMPROVEMENT_PLAN.md
Normal file
114
IMPROVEMENT_PLAN.md
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
# 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
Normal file
354
MCP_DESIGN.md
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
# 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
|
namespace Schaad.Accounting
|
||||||
{
|
{
|
||||||
public class ClassIds
|
public static class ClassIds
|
||||||
{
|
{
|
||||||
public const int Activa = 1;
|
public const int Activa = 1;
|
||||||
public const int Passiva = 2;
|
public const int Passiva = 2;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
namespace Schaad.Accounting.Datasets.Charts
|
||||||
|
{
|
||||||
|
public record CategoryTotal(string Name, decimal Value);
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,10 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Schaad.Accounting.Datasets.Reports
|
namespace Schaad.Accounting.Datasets.Reports
|
||||||
{
|
{
|
||||||
public class BalanceDataset
|
public record BalanceDataset(
|
||||||
{
|
List<AccountDataset> ActivaAccountList,
|
||||||
public List<AccountDataset> ActivaAccountList { get; set; }
|
List<AccountDataset> PassivaAccountList,
|
||||||
|
decimal TotalActivaCHF,
|
||||||
public List<AccountDataset> PassivaAccountList { get; set; }
|
decimal TotalPassivaCHF);
|
||||||
|
}
|
||||||
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,29 +1,12 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Schaad.Accounting.Datasets.Reports
|
namespace Schaad.Accounting.Datasets.Reports
|
||||||
{
|
{
|
||||||
public class BalanceSheetDataset
|
public record BalanceSheetDataset(
|
||||||
{
|
List<AccountDataset> ActivaAccountList,
|
||||||
public List<AccountDataset> ActivaAccountList { get; }
|
List<AccountDataset> IncomeAccountList,
|
||||||
|
List<AccountDataset> ExpensesAccountList,
|
||||||
public List<AccountDataset> IncomeAccountList { get; }
|
decimal ProfitCHF,
|
||||||
|
decimal LossCHF,
|
||||||
public List<AccountDataset> ExpensesAccountList { get; }
|
int Year);
|
||||||
|
}
|
||||||
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,5 +5,10 @@
|
||||||
public string DataPath { get; set; }
|
public string DataPath { get; set; }
|
||||||
|
|
||||||
public string FixerIoApiKey { get; set; }
|
public string FixerIoApiKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mandator selected when the app starts. Users can switch via the header dropdown.
|
||||||
|
/// </summary>
|
||||||
|
public string DefaultMandator { get; set; } = "Claudio Schaad";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -33,5 +33,30 @@ namespace Schaad.Accounting.Datasets
|
||||||
else
|
else
|
||||||
return Value;
|
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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
19
Schaad.Accounting.Common/Formatting.cs
Normal file
19
Schaad.Accounting.Common/Formatting.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
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,20 +1,21 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
||||||
namespace Schaad.Accounting.Interfaces
|
namespace Schaad.Accounting.Interfaces
|
||||||
{
|
{
|
||||||
public interface IAccountRepository
|
public interface IAccountRepository
|
||||||
{
|
{
|
||||||
List<Account> GetAccountList();
|
Task<List<Account>> GetAccountListAsync();
|
||||||
|
|
||||||
void SaveAccount(Account account);
|
Task SaveAccountAsync(Account account);
|
||||||
|
|
||||||
Account GetAccount(string id);
|
Task<Account> GetAccountAsync(string id);
|
||||||
|
|
||||||
void DeleteAccount(string id);
|
|
||||||
|
|
||||||
Account GetAccountForBankAccountNumber(string bankAccountNumber);
|
Task DeleteAccountAsync(string id);
|
||||||
|
|
||||||
void SaveBankAccountBalance(string bankAccountNumber, decimal accountBalance);
|
Task<Account> GetAccountForBankAccountNumberAsync(string bankAccountNumber);
|
||||||
|
|
||||||
|
Task SaveBankAccountBalanceAsync(string bankAccountNumber, decimal accountBalance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Schaad.Accounting.Datasets;
|
using Schaad.Accounting.Datasets;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
||||||
|
|
@ -6,10 +7,10 @@ namespace Schaad.Accounting.Interfaces
|
||||||
{
|
{
|
||||||
public interface IBankTransactionRepository
|
public interface IBankTransactionRepository
|
||||||
{
|
{
|
||||||
List<BankTransaction> GetBankTransactionList();
|
Task<List<BankTransaction>> GetBankTransactionListAsync();
|
||||||
|
|
||||||
int SaveBankTransactionList(string bankAccountNumber, List<BankTransactionDataset> bankTransactions);
|
Task<int> SaveBankTransactionListAsync(string bankAccountNumber, List<BankTransactionDataset> bankTransactions);
|
||||||
|
|
||||||
BankTransaction GetBankTransaction(string id);
|
Task<BankTransaction> GetBankTransactionAsync(string id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
||||||
namespace Schaad.Accounting.Interfaces
|
namespace Schaad.Accounting.Interfaces
|
||||||
{
|
{
|
||||||
public interface IBookingRuleRepository
|
public interface IBookingRuleRepository
|
||||||
{
|
{
|
||||||
List<BookingRule> GetBookingRuleList();
|
Task<List<BookingRule>> GetBookingRuleListAsync();
|
||||||
|
|
||||||
void SaveBookingRule(BookingRule bookingRule);
|
Task SaveBookingRuleAsync(BookingRule bookingRule);
|
||||||
|
|
||||||
BookingRule GetBookingRule(string id);
|
Task<BookingRule> GetBookingRuleAsync(string id);
|
||||||
void DeleteBookingRule(string id);
|
|
||||||
|
Task DeleteBookingRuleAsync(string id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
||||||
namespace Schaad.Accounting.Interfaces
|
namespace Schaad.Accounting.Interfaces
|
||||||
{
|
{
|
||||||
public interface IBookingTextRepository
|
public interface IBookingTextRepository
|
||||||
{
|
{
|
||||||
List<BookingText> GetBookingTextList();
|
Task<List<BookingText>> GetBookingTextListAsync();
|
||||||
|
|
||||||
void SaveBookingText(BookingText bookingText);
|
Task SaveBookingTextAsync(BookingText bookingText);
|
||||||
|
|
||||||
BookingText GetBookingText(string id);
|
Task<BookingText> GetBookingTextAsync(string id);
|
||||||
|
|
||||||
void DeleteBookingText(string id);
|
Task DeleteBookingTextAsync(string id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
using System.Collections.Generic;
|
|
||||||
using Schaad.Accounting.Datasets.Charts;
|
|
||||||
|
|
||||||
namespace Schaad.Accounting.Interfaces
|
|
||||||
{
|
|
||||||
public interface IChartService
|
|
||||||
{
|
|
||||||
IReadOnlyList<DataSerie> GetExpensesPerMonth();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
using System.Collections.Generic;
|
|
||||||
using Schaad.Accounting.Models;
|
|
||||||
|
|
||||||
namespace Schaad.Accounting.Interfaces
|
|
||||||
{
|
|
||||||
public interface ISplitPredefinitonRepository
|
|
||||||
{
|
|
||||||
List<SplitPredefinition> GetSplitPredefinitionList();
|
|
||||||
|
|
||||||
void SaveSplitPredefinition(SplitPredefinition splitPredefinition);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +1,21 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
||||||
namespace Schaad.Accounting.Interfaces
|
namespace Schaad.Accounting.Interfaces
|
||||||
{
|
{
|
||||||
public interface ISubclassRepository
|
public interface ISubclassRepository
|
||||||
{
|
{
|
||||||
List<SubClass> GetSubClassList();
|
Task<List<SubClass>> GetSubClassListAsync();
|
||||||
|
|
||||||
void SaveSubClass(SubClass subClass);
|
Task SaveSubClassAsync(SubClass subClass);
|
||||||
|
|
||||||
SubClass GetSubClass(string id);
|
Task<SubClass> GetSubClassAsync(string id);
|
||||||
|
|
||||||
void DeleteSubClass(string id);
|
Task DeleteSubClassAsync(string id);
|
||||||
|
|
||||||
Dictionary<int, string> GetClasses();
|
Dictionary<int, string> GetClasses();
|
||||||
|
|
||||||
string GetClass(int classId);
|
string GetClass(int classId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
||||||
namespace Schaad.Accounting.Interfaces
|
namespace Schaad.Accounting.Interfaces
|
||||||
{
|
{
|
||||||
public interface ITransactionRepository
|
public interface ITransactionRepository
|
||||||
{
|
{
|
||||||
List<Transaction> GetTransactionList();
|
Task<List<Transaction>> GetTransactionListAsync();
|
||||||
|
|
||||||
void SaveTransaction(Transaction transaction);
|
Task SaveTransactionAsync(Transaction transaction);
|
||||||
|
|
||||||
Transaction GetTransaction(string id);
|
Task<Transaction> GetTransactionAsync(string id);
|
||||||
void DeleteTransaction(string id);
|
|
||||||
|
Task DeleteTransactionAsync(string id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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,19 +59,5 @@ namespace Schaad.Accounting.Models
|
||||||
{
|
{
|
||||||
get { return string.IsNullOrEmpty(Currency) == false && Currency != "CHF"; }
|
get { return string.IsNullOrEmpty(Currency) == false && Currency != "CHF"; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Makes a copy
|
|
||||||
/// </summary>
|
|
||||||
public void Copy(Account target)
|
|
||||||
{
|
|
||||||
target.LastBankBalance = LastBankBalance;
|
|
||||||
target.StartBalance = StartBalance;
|
|
||||||
target.BankAccountNumber = BankAccountNumber;
|
|
||||||
target.Currency = string.IsNullOrEmpty(Currency) ? "CHF" : Currency;
|
|
||||||
target.Id = Id;
|
|
||||||
target.Name = Name;
|
|
||||||
target.Number = Number;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -20,18 +20,5 @@ namespace Schaad.Accounting.Models
|
||||||
[Display(Name = "Konto")]
|
[Display(Name = "Konto")]
|
||||||
[Required]
|
[Required]
|
||||||
public string AccountId { get; set; }
|
public string AccountId { get; set; }
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Makes a copy
|
|
||||||
/// </summary>
|
|
||||||
public void Copy(BookingRule target)
|
|
||||||
{
|
|
||||||
target.LookupText = LookupText;
|
|
||||||
target.LookupValue = LookupValue;
|
|
||||||
target.BookingText = BookingText;
|
|
||||||
target.Id = Id;
|
|
||||||
target.AccountId = AccountId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -10,14 +10,5 @@ namespace Schaad.Accounting.Models
|
||||||
[Required]
|
[Required]
|
||||||
[MinLength(3)]
|
[MinLength(3)]
|
||||||
public string Text { get; set; }
|
public string Text { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Makes a copy
|
|
||||||
/// </summary>
|
|
||||||
public void Copy(BookingText target)
|
|
||||||
{
|
|
||||||
target.Id = Id;
|
|
||||||
target.Text = Text;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -9,17 +9,5 @@
|
||||||
public decimal BookingValue { get; set; }
|
public decimal BookingValue { get; set; }
|
||||||
|
|
||||||
public string AccountId { get; set; }
|
public string AccountId { get; set; }
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Makes a copy
|
|
||||||
/// </summary>
|
|
||||||
public void Copy(SplitPredefinition target)
|
|
||||||
{
|
|
||||||
target.BookingText = BookingText;
|
|
||||||
target.BookingValue = BookingValue;
|
|
||||||
target.Id = Id;
|
|
||||||
target.AccountId = AccountId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -14,15 +14,5 @@ namespace Schaad.Accounting.Models
|
||||||
[Required]
|
[Required]
|
||||||
[MinLength(3)]
|
[MinLength(3)]
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Makes a copy
|
|
||||||
/// </summary>
|
|
||||||
public void Copy(SubClass target)
|
|
||||||
{
|
|
||||||
target.Id = Id;
|
|
||||||
target.Name = Name;
|
|
||||||
target.Number = Number;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Xml.Serialization;
|
||||||
|
|
||||||
namespace Schaad.Accounting.Models
|
namespace Schaad.Accounting.Models
|
||||||
{
|
{
|
||||||
|
|
@ -42,6 +43,9 @@ namespace Schaad.Accounting.Models
|
||||||
|
|
||||||
[Display(Name = "Währungskurs")]
|
[Display(Name = "Währungskurs")]
|
||||||
public decimal? FxRate { get; set; }
|
public decimal? FxRate { get; set; }
|
||||||
|
|
||||||
|
[XmlIgnore]
|
||||||
|
public bool ReadOnly {get; set;}
|
||||||
|
|
||||||
public Transaction()
|
public Transaction()
|
||||||
{
|
{
|
||||||
|
|
@ -70,20 +74,22 @@ namespace Schaad.Accounting.Models
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Makes a copy
|
/// Returns a shallow independent copy — used to hand out defensive copies
|
||||||
|
/// (e.g. from repository reads) without exposing the caller to later mutation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Copy(Transaction target)
|
public Transaction Clone() => new()
|
||||||
{
|
{
|
||||||
target.BankTransactionId = BankTransactionId;
|
Id = Id,
|
||||||
target.BankTransactionText = BankTransactionText;
|
BankTransactionId = BankTransactionId,
|
||||||
target.Id = Id;
|
BankTransactionText = BankTransactionText,
|
||||||
target.OriginAccountId = OriginAccountId;
|
RelatedParty = RelatedParty,
|
||||||
target.TargetAccountId = TargetAccountId;
|
OriginAccountId = OriginAccountId,
|
||||||
target.Text = Text;
|
TargetAccountId = TargetAccountId,
|
||||||
target.Value = Value;
|
Text = Text,
|
||||||
target.ValueDate = ValueDate;
|
Value = Value,
|
||||||
target.BookingDate = BookingDate > DateTime.MinValue ? BookingDate : ValueDate;
|
ValueDate = ValueDate,
|
||||||
target.FxRate = FxRate;
|
BookingDate = BookingDate,
|
||||||
}
|
FxRate = FxRate
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,17 +4,4 @@
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<RootNamespace>Schaad.Accounting</RootNamespace>
|
<RootNamespace>Schaad.Accounting</RootNamespace>
|
||||||
</PropertyGroup>
|
</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>
|
</Project>
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
||||||
|
|
@ -11,104 +13,98 @@ namespace Schaad.Accounting.Repositories
|
||||||
{
|
{
|
||||||
private readonly string ACCOUNTS = "Accounts.xml";
|
private readonly string ACCOUNTS = "Accounts.xml";
|
||||||
|
|
||||||
public AccountRepository(ISettingsService settingsService) : base(settingsService)
|
public AccountRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<AccountRepository> logger)
|
||||||
|
: base(settingsService, cache, logger)
|
||||||
{
|
{
|
||||||
EnsureFileExisits(ACCOUNTS);
|
EnsureAccountsFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<List<Account>> GetAccountListAsync() => LoadListAsync<Account>(ACCOUNTS);
|
||||||
|
|
||||||
/// <summary>
|
public async Task SaveAccountAsync(Account account)
|
||||||
/// Load accounts
|
|
||||||
/// </summary>
|
|
||||||
public List<Account> GetAccountList()
|
|
||||||
{
|
{
|
||||||
var accounts = Load<List<Account>>(ACCOUNTS);
|
if (string.IsNullOrEmpty(account.Currency))
|
||||||
return accounts ?? new List<Account>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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();
|
account.Currency = "CHF";
|
||||||
accounts.Add(existingAccount);
|
|
||||||
account.Id = Guid.NewGuid().ToString();
|
|
||||||
}
|
}
|
||||||
account.Copy(existingAccount);
|
|
||||||
Save(accounts, ACCOUNTS);
|
var accounts = await GetAccountListAsync();
|
||||||
|
var idx = accounts.FindIndex(a => a.Id == account.Id);
|
||||||
|
if (idx >= 0)
|
||||||
|
{
|
||||||
|
accounts[idx] = account;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
account.Id = Guid.NewGuid().ToString();
|
||||||
|
accounts.Add(account);
|
||||||
|
}
|
||||||
|
await SaveAsync(accounts, ACCOUNTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task<Account> GetAccountAsync(string id)
|
||||||
/// Get account
|
|
||||||
/// </summary>
|
|
||||||
public Account GetAccount(string id)
|
|
||||||
{
|
{
|
||||||
var accounts = GetAccountList();
|
var accounts = await GetAccountListAsync();
|
||||||
return accounts.FirstOrDefault(t => t.Id == id);
|
return accounts.FirstOrDefault(t => t.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task DeleteAccountAsync(string id)
|
||||||
/// Delete account
|
|
||||||
/// </summary>
|
|
||||||
public void DeleteAccount(string id)
|
|
||||||
{
|
{
|
||||||
var accounts = GetAccountList();
|
var accounts = await GetAccountListAsync();
|
||||||
accounts.RemoveAll(t => t.Id == id);
|
accounts.RemoveAll(t => t.Id == id);
|
||||||
Save(accounts, ACCOUNTS);
|
await SaveAsync(accounts, ACCOUNTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task<Account> GetAccountForBankAccountNumberAsync(string bankAccountNumber)
|
||||||
/// Get account
|
|
||||||
/// </summary>
|
|
||||||
public Account GetAccountForBankAccountNumber(string bankAccountNumber)
|
|
||||||
{
|
{
|
||||||
var accounts = GetAccountList();
|
var accounts = await GetAccountListAsync();
|
||||||
return accounts.SingleOrDefault(t => t.BankAccountNumber == bankAccountNumber);
|
return accounts.SingleOrDefault(t => t.BankAccountNumber == bankAccountNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SaveBankAccountBalance(string bankAccountNumber, decimal accountBalance)
|
public async Task SaveBankAccountBalanceAsync(string bankAccountNumber, decimal accountBalance)
|
||||||
{
|
{
|
||||||
var account = GetAccountForBankAccountNumber(bankAccountNumber);
|
var account = await GetAccountForBankAccountNumberAsync(bankAccountNumber);
|
||||||
account.LastBankBalance = accountBalance;
|
account.LastBankBalance = accountBalance;
|
||||||
SaveAccount(account);
|
await SaveAccountAsync(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
private new void EnsureFileExisits(string file)
|
private void EnsureAccountsFile()
|
||||||
{
|
{
|
||||||
string filePath = Path.Combine(settingsService.GetDbPath(), file);
|
string filePath = Path.Combine(settingsService.GetDbPath(), ACCOUNTS);
|
||||||
if (File.Exists(filePath) == false)
|
if (File.Exists(filePath))
|
||||||
{
|
{
|
||||||
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), file);
|
return;
|
||||||
if (File.Exists(lastYearFile))
|
}
|
||||||
File.Copy(lastYearFile, filePath);
|
|
||||||
|
|
||||||
// set start balance to last bank balance
|
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), ACCOUNTS);
|
||||||
if (file.IndexOf("Accounts") > -1)
|
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)
|
||||||
{
|
{
|
||||||
var accounts = GetAccountList();
|
account.StartBalance = account.LastBankBalance;
|
||||||
foreach (var account in accounts.Where(a => a.Class == ClassIds.Activa))
|
modified = true;
|
||||||
{
|
|
||||||
// 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,5 +1,7 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Schaad.Accounting.Datasets;
|
using Schaad.Accounting.Datasets;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
@ -10,26 +12,20 @@ namespace Schaad.Accounting.Repositories
|
||||||
{
|
{
|
||||||
private readonly string BANK_TRANSACTIONS = "BankTransactions.xml";
|
private readonly string BANK_TRANSACTIONS = "BankTransactions.xml";
|
||||||
|
|
||||||
public BankTransactionRepository(ISettingsService settingsService) : base(settingsService)
|
public BankTransactionRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<BankTransactionRepository> logger)
|
||||||
|
: base(settingsService, cache, logger)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public Task<List<BankTransaction>> GetBankTransactionListAsync() => LoadListAsync<BankTransaction>(BANK_TRANSACTIONS);
|
||||||
/// Load bank transactions
|
|
||||||
/// </summary>
|
|
||||||
public List<BankTransaction> GetBankTransactionList()
|
|
||||||
{
|
|
||||||
var transactions = Load<List<BankTransaction>>(BANK_TRANSACTIONS);
|
|
||||||
return transactions != null ? transactions : new List<BankTransaction>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Save new bank transactions, ignore existing ones (no update)
|
/// Save new bank transactions, ignore existing ones (no update)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int SaveBankTransactionList(string bankAccountNumber, List<BankTransactionDataset> bankTransactions)
|
public async Task<int> SaveBankTransactionListAsync(string bankAccountNumber, List<BankTransactionDataset> bankTransactions)
|
||||||
{
|
{
|
||||||
var count = 0;
|
var count = 0;
|
||||||
var transactions = GetBankTransactionList();
|
var transactions = await GetBankTransactionListAsync();
|
||||||
foreach (var bankTrx in bankTransactions)
|
foreach (var bankTrx in bankTransactions)
|
||||||
{
|
{
|
||||||
var transaction = transactions.FirstOrDefault(t => t.Id == bankTrx.Id);
|
var transaction = transactions.FirstOrDefault(t => t.Id == bankTrx.Id);
|
||||||
|
|
@ -50,17 +46,14 @@ namespace Schaad.Accounting.Repositories
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Save(transactions, BANK_TRANSACTIONS);
|
await SaveAsync(transactions, BANK_TRANSACTIONS);
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task<BankTransaction> GetBankTransactionAsync(string id)
|
||||||
/// Get bank transaction
|
|
||||||
/// </summary>
|
|
||||||
public BankTransaction GetBankTransaction(string id)
|
|
||||||
{
|
{
|
||||||
var transactions = GetBankTransactionList();
|
var transactions = await GetBankTransactionListAsync();
|
||||||
return transactions.FirstOrDefault(t => t.Id == id);
|
return transactions.FirstOrDefault(t => t.Id == id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,153 @@
|
||||||
using System.IO;
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using System.Xml;
|
using System.Xml;
|
||||||
using System.Xml.Serialization;
|
using System.Xml.Serialization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
|
|
||||||
namespace Schaad.Accounting.Repositories
|
namespace Schaad.Accounting.Repositories
|
||||||
{
|
{
|
||||||
public abstract class BaseRepository
|
public abstract class BaseRepository
|
||||||
{
|
{
|
||||||
protected readonly ISettingsService settingsService;
|
// 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 BaseRepository(ISettingsService settingsService)
|
protected readonly ISettingsService settingsService;
|
||||||
|
protected readonly RepositoryCache cache;
|
||||||
|
protected readonly ILogger logger;
|
||||||
|
|
||||||
|
protected BaseRepository(ISettingsService settingsService, RepositoryCache cache, ILogger logger)
|
||||||
{
|
{
|
||||||
this.settingsService = settingsService;
|
this.settingsService = settingsService;
|
||||||
|
this.cache = cache;
|
||||||
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void EnsureFileExisits(string fileName)
|
/// <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)
|
||||||
{
|
{
|
||||||
string filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
string filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
||||||
if (File.Exists(filePath) == false)
|
var semaphore = GetLock(filePath);
|
||||||
|
semaphore.Wait();
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), fileName);
|
if (File.Exists(filePath) == false)
|
||||||
if (File.Exists(lastYearFile))
|
|
||||||
{
|
{
|
||||||
File.Copy(lastYearFile, filePath);
|
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), fileName);
|
||||||
|
if (File.Exists(lastYearFile))
|
||||||
|
{
|
||||||
|
File.Copy(lastYearFile, filePath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
semaphore.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Save an object to an xml file
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
protected void Save<T>(T obj, string fileName)
|
protected Task<List<T>> LoadListAsync<T>(string fileName)
|
||||||
{
|
{
|
||||||
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
||||||
using (var sww = new MemoryStream())
|
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
|
||||||
{
|
{
|
||||||
var settings = new XmlWriterSettings
|
var settings = new XmlWriterSettings
|
||||||
{
|
{
|
||||||
Encoding = Encoding.UTF8,
|
Encoding = Encoding.UTF8,
|
||||||
Indent = true
|
Indent = true
|
||||||
};
|
};
|
||||||
using (var writer = XmlWriter.Create(sww, settings))
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var xsSubmit = new XmlSerializer(typeof(T));
|
// XmlSerializer is synchronous — serialize to memory, then write async.
|
||||||
xsSubmit.Serialize(writer, obj);
|
using var memory = new MemoryStream();
|
||||||
var xml = Encoding.UTF8.GetString(sww.ToArray());
|
using (var writer = XmlWriter.Create(memory, settings))
|
||||||
File.WriteAllText(filePath, xml);
|
{
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
semaphore.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.Invalidate(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Load an object from an xml file
|
/// Load an object from an xml file (uncached; used for one-off reads such as file
|
||||||
|
/// existence bootstrapping). Prefer <see cref="LoadListAsync{T}"/> for repository reads.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
protected T Load<T>(string fileName)
|
protected async Task<T> LoadAsync<T>(string fileName)
|
||||||
{
|
{
|
||||||
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
||||||
if (File.Exists(filePath) == false)
|
|
||||||
{
|
|
||||||
return default(T);
|
|
||||||
}
|
|
||||||
|
|
||||||
using (XmlReader reader = XmlReader.Create(filePath))
|
var semaphore = GetLock(filePath);
|
||||||
|
await semaphore.WaitAsync();
|
||||||
|
try
|
||||||
{
|
{
|
||||||
|
if (File.Exists(filePath) == false)
|
||||||
|
{
|
||||||
|
return default(T);
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = await File.ReadAllBytesAsync(filePath);
|
||||||
|
using var memory = new MemoryStream(bytes);
|
||||||
var serializer = new XmlSerializer(typeof(T));
|
var serializer = new XmlSerializer(typeof(T));
|
||||||
return (T)serializer.Deserialize(reader);
|
return (T)serializer.Deserialize(memory);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
semaphore.Release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static SemaphoreSlim GetLock(string filePath)
|
||||||
|
=> FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
||||||
|
|
@ -10,55 +12,41 @@ namespace Schaad.Accounting.Repositories
|
||||||
{
|
{
|
||||||
private readonly string BOOKING_RULES = "BookingRules.xml";
|
private readonly string BOOKING_RULES = "BookingRules.xml";
|
||||||
|
|
||||||
public BookingRuleRepository(ISettingsService settingsService) : base(settingsService)
|
public BookingRuleRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<BookingRuleRepository> logger)
|
||||||
|
: base(settingsService, cache, logger)
|
||||||
{
|
{
|
||||||
EnsureFileExisits(BOOKING_RULES);
|
EnsureFileExists(BOOKING_RULES);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public Task<List<BookingRule>> GetBookingRuleListAsync() => LoadListAsync<BookingRule>(BOOKING_RULES);
|
||||||
/// Load booking rules
|
|
||||||
/// </summary>
|
|
||||||
public List<BookingRule> GetBookingRuleList()
|
|
||||||
{
|
|
||||||
var bookingTexts = Load<List<BookingRule>>(BOOKING_RULES);
|
|
||||||
return bookingTexts ?? new List<BookingRule>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public async Task SaveBookingRuleAsync(BookingRule bookingRule)
|
||||||
/// Save a booking rule (insert/update)
|
|
||||||
/// </summary>
|
|
||||||
public void SaveBookingRule(BookingRule bookingRule)
|
|
||||||
{
|
{
|
||||||
var bookingRules = GetBookingRuleList();
|
var bookingRules = await GetBookingRuleListAsync();
|
||||||
var existingRule = bookingRules.FirstOrDefault(a => a.Id == bookingRule.Id);
|
var idx = bookingRules.FindIndex(r => r.Id == bookingRule.Id);
|
||||||
|
if (idx >= 0)
|
||||||
if (existingRule == null)
|
|
||||||
{
|
{
|
||||||
existingRule = new BookingRule();
|
bookingRules[idx] = bookingRule;
|
||||||
bookingRules.Add(existingRule);
|
|
||||||
bookingRule.Id = Guid.NewGuid().ToString();
|
|
||||||
}
|
}
|
||||||
bookingRule.Copy(existingRule);
|
else
|
||||||
Save(bookingRules, BOOKING_RULES);
|
{
|
||||||
|
bookingRule.Id = Guid.NewGuid().ToString();
|
||||||
|
bookingRules.Add(bookingRule);
|
||||||
|
}
|
||||||
|
await SaveAsync(bookingRules, BOOKING_RULES);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task<BookingRule> GetBookingRuleAsync(string id)
|
||||||
/// Get booking rule
|
|
||||||
/// </summary>
|
|
||||||
public BookingRule GetBookingRule(string id)
|
|
||||||
{
|
{
|
||||||
var bookingRules = GetBookingRuleList();
|
var bookingRules = await GetBookingRuleListAsync();
|
||||||
return bookingRules.FirstOrDefault(t => t.Id == id);
|
return bookingRules.FirstOrDefault(t => t.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task DeleteBookingRuleAsync(string id)
|
||||||
/// Delete booking rule
|
|
||||||
/// </summary>
|
|
||||||
public void DeleteBookingRule(string id)
|
|
||||||
{
|
{
|
||||||
var bookingRules = GetBookingRuleList();
|
var bookingRules = await GetBookingRuleListAsync();
|
||||||
bookingRules.RemoveAll(t => t.Id == id);
|
bookingRules.RemoveAll(t => t.Id == id);
|
||||||
Save(bookingRules, BOOKING_RULES);
|
await SaveAsync(bookingRules, BOOKING_RULES);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
||||||
|
|
@ -10,55 +12,41 @@ namespace Schaad.Accounting.Repositories
|
||||||
{
|
{
|
||||||
private readonly string BOOKING_TEXTS = "BookingTexts.xml";
|
private readonly string BOOKING_TEXTS = "BookingTexts.xml";
|
||||||
|
|
||||||
public BookingTextRepository(ISettingsService settingsService) : base(settingsService)
|
public BookingTextRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<BookingTextRepository> logger)
|
||||||
|
: base(settingsService, cache, logger)
|
||||||
{
|
{
|
||||||
EnsureFileExisits(BOOKING_TEXTS);
|
EnsureFileExists(BOOKING_TEXTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public Task<List<BookingText>> GetBookingTextListAsync() => LoadListAsync<BookingText>(BOOKING_TEXTS);
|
||||||
/// Load bokking texts
|
|
||||||
/// </summary>
|
|
||||||
public List<BookingText> GetBookingTextList()
|
|
||||||
{
|
|
||||||
var bookingTexts = Load<List<BookingText>>(BOOKING_TEXTS);
|
|
||||||
return bookingTexts ?? new List<BookingText>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public async Task SaveBookingTextAsync(BookingText bookingText)
|
||||||
/// Save a booking text (insert/update)
|
|
||||||
/// </summary>
|
|
||||||
public void SaveBookingText(BookingText bookingText)
|
|
||||||
{
|
{
|
||||||
var bookingTexts = GetBookingTextList();
|
var bookingTexts = await GetBookingTextListAsync();
|
||||||
var existingText = bookingTexts.FirstOrDefault(a => a.Id == bookingText.Id);
|
var idx = bookingTexts.FindIndex(t => t.Id == bookingText.Id);
|
||||||
|
if (idx >= 0)
|
||||||
if (existingText == null)
|
|
||||||
{
|
{
|
||||||
existingText = new BookingText();
|
bookingTexts[idx] = bookingText;
|
||||||
bookingTexts.Add(existingText);
|
|
||||||
bookingText.Id = Guid.NewGuid().ToString();
|
|
||||||
}
|
}
|
||||||
bookingText.Copy(existingText);
|
else
|
||||||
Save(bookingTexts, BOOKING_TEXTS);
|
{
|
||||||
|
bookingText.Id = Guid.NewGuid().ToString();
|
||||||
|
bookingTexts.Add(bookingText);
|
||||||
|
}
|
||||||
|
await SaveAsync(bookingTexts, BOOKING_TEXTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task<BookingText> GetBookingTextAsync(string id)
|
||||||
/// Get booking text
|
|
||||||
/// </summary>
|
|
||||||
public BookingText GetBookingText(string id)
|
|
||||||
{
|
{
|
||||||
var bookingTexts = GetBookingTextList();
|
var bookingTexts = await GetBookingTextListAsync();
|
||||||
return bookingTexts.FirstOrDefault(t => t.Id == id);
|
return bookingTexts.FirstOrDefault(t => t.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task DeleteBookingTextAsync(string id)
|
||||||
/// Delete booking text
|
|
||||||
/// </summary>
|
|
||||||
public void DeleteBookingText(string id)
|
|
||||||
{
|
{
|
||||||
var bookingTexts = GetBookingTextList();
|
var bookingTexts = await GetBookingTextListAsync();
|
||||||
bookingTexts.RemoveAll(t => t.Id == id);
|
bookingTexts.RemoveAll(t => t.Id == id);
|
||||||
Save(bookingTexts, BOOKING_TEXTS);
|
await SaveAsync(bookingTexts, BOOKING_TEXTS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
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,6 +1,8 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
||||||
|
|
@ -11,9 +13,10 @@ namespace Schaad.Accounting.Repositories
|
||||||
private readonly Dictionary<int, string> classes = new Dictionary<int, string>();
|
private readonly Dictionary<int, string> classes = new Dictionary<int, string>();
|
||||||
private readonly string SUBCLASSES = "SubClasses.xml";
|
private readonly string SUBCLASSES = "SubClasses.xml";
|
||||||
|
|
||||||
public SubclassRepository(ISettingsService settingsService) : base(settingsService)
|
public SubclassRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<SubclassRepository> logger)
|
||||||
|
: base(settingsService, cache, logger)
|
||||||
{
|
{
|
||||||
EnsureFileExisits(SUBCLASSES);
|
EnsureFileExists(SUBCLASSES);
|
||||||
|
|
||||||
classes.Add(ClassIds.Activa, "Aktiven");
|
classes.Add(ClassIds.Activa, "Aktiven");
|
||||||
classes.Add(ClassIds.Passiva, "Passiven");
|
classes.Add(ClassIds.Passiva, "Passiven");
|
||||||
|
|
@ -21,47 +24,35 @@ namespace Schaad.Accounting.Repositories
|
||||||
classes.Add(ClassIds.Expenses, "Ausgaben");
|
classes.Add(ClassIds.Expenses, "Ausgaben");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public Task<List<SubClass>> GetSubClassListAsync() => LoadListAsync<SubClass>(SUBCLASSES);
|
||||||
/// Load bokking texts
|
|
||||||
/// </summary>
|
|
||||||
public List<SubClass> GetSubClassList()
|
|
||||||
{
|
|
||||||
var subclasses = Load<List<SubClass>>(SUBCLASSES);
|
|
||||||
return subclasses ?? new List<SubClass>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public async Task SaveSubClassAsync(SubClass subClass)
|
||||||
/// Save a booking text (insert/update)
|
|
||||||
/// </summary>
|
|
||||||
public void SaveSubClass(SubClass subClass)
|
|
||||||
{
|
{
|
||||||
var subclasses = GetSubClassList();
|
var subclasses = await GetSubClassListAsync();
|
||||||
var existingSubClass = subclasses.FirstOrDefault(a => a.Id == subClass.Id);
|
var idx = subclasses.FindIndex(s => s.Id == subClass.Id);
|
||||||
|
if (idx >= 0)
|
||||||
if (existingSubClass == null)
|
|
||||||
{
|
{
|
||||||
existingSubClass = new SubClass();
|
subclasses[idx] = subClass;
|
||||||
subclasses.Add(existingSubClass);
|
|
||||||
subClass.Id = Guid.NewGuid().ToString();
|
|
||||||
}
|
}
|
||||||
subClass.Copy(existingSubClass);
|
else
|
||||||
Save(subclasses, SUBCLASSES);
|
{
|
||||||
|
subClass.Id = Guid.NewGuid().ToString();
|
||||||
|
subclasses.Add(subClass);
|
||||||
|
}
|
||||||
|
await SaveAsync(subclasses, SUBCLASSES);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task<SubClass> GetSubClassAsync(string id)
|
||||||
/// Get subclass
|
|
||||||
/// </summary>
|
|
||||||
public SubClass GetSubClass(string id)
|
|
||||||
{
|
{
|
||||||
var subclasses = GetSubClassList();
|
var subclasses = await GetSubClassListAsync();
|
||||||
return subclasses.FirstOrDefault(t => t.Id == id);
|
return subclasses.FirstOrDefault(t => t.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void DeleteSubClass(string id)
|
public async Task DeleteSubClassAsync(string id)
|
||||||
{
|
{
|
||||||
var subclasses = GetSubClassList();
|
var subclasses = await GetSubClassListAsync();
|
||||||
subclasses.RemoveAll(t => t.Id == id);
|
subclasses.RemoveAll(t => t.Id == id);
|
||||||
Save(subclasses, SUBCLASSES);
|
await SaveAsync(subclasses, SUBCLASSES);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Dictionary<int, string> GetClasses()
|
public Dictionary<int, string> GetClasses()
|
||||||
|
|
@ -74,4 +65,4 @@ namespace Schaad.Accounting.Repositories
|
||||||
return classes[classId];
|
return classes[classId];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
||||||
|
|
@ -11,82 +13,79 @@ namespace Schaad.Accounting.Repositories
|
||||||
private readonly IAccountRepository accountRepository;
|
private readonly IAccountRepository accountRepository;
|
||||||
private readonly string TRANSACTIONS = "Transactions.xml";
|
private readonly string TRANSACTIONS = "Transactions.xml";
|
||||||
|
|
||||||
public TransactionRepository(ISettingsService settingsService, IAccountRepository accountRepository) : base(settingsService)
|
public TransactionRepository(ISettingsService settingsService, RepositoryCache cache, IAccountRepository accountRepository, ILogger<TransactionRepository> logger)
|
||||||
|
: base(settingsService, cache, logger)
|
||||||
{
|
{
|
||||||
this.accountRepository = accountRepository;
|
this.accountRepository = accountRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public Task<List<Transaction>> GetTransactionListAsync() => LoadListAsync<Transaction>(TRANSACTIONS);
|
||||||
/// Get transaction list
|
|
||||||
/// </summary>
|
|
||||||
public List<Transaction> GetTransactionList()
|
|
||||||
{
|
|
||||||
var transactionList = Load<List<Transaction>>(TRANSACTIONS);
|
|
||||||
return transactionList ?? new List<Transaction>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public async Task SaveTransactionAsync(Transaction transaction)
|
||||||
/// Save a transaction (insert/update)
|
|
||||||
/// </summary>
|
|
||||||
public void SaveTransaction(Transaction transaction)
|
|
||||||
{
|
{
|
||||||
transaction.Value = Math.Abs(transaction.Value);
|
transaction.Value = Math.Abs(transaction.Value);
|
||||||
|
|
||||||
// prevent saving fxrate for chf account
|
|
||||||
var isFxAccount = accountRepository.GetAccount(transaction.OriginAccountId).IsFxAccount;
|
|
||||||
|
|
||||||
// if source account is fx account, we pass a foreign value -> calc to chf
|
var originIsFx = (await accountRepository.GetAccountAsync(transaction.OriginAccountId)).IsFxAccount;
|
||||||
|
var targetIsFx = (await accountRepository.GetAccountAsync(transaction.TargetAccountId)).IsFxAccount;
|
||||||
|
var isFxAccount = originIsFx || targetIsFx;
|
||||||
|
|
||||||
|
// user enters amount in the foreign account's currency -> convert to CHF for storage
|
||||||
if (isFxAccount && transaction.FxRate != 0)
|
if (isFxAccount && transaction.FxRate != 0)
|
||||||
{
|
{
|
||||||
transaction.Value = transaction.Value * transaction.FxRate.Value;
|
transaction.Value = transaction.Value * transaction.FxRate.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
isFxAccount |= accountRepository.GetAccount(transaction.TargetAccountId).IsFxAccount;
|
if (!isFxAccount)
|
||||||
if (isFxAccount == false)
|
|
||||||
{
|
{
|
||||||
transaction.FxRate = null;
|
transaction.FxRate = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (transaction.BookingDate <= DateTime.MinValue)
|
||||||
var transactionList = GetTransactionList();
|
{
|
||||||
var existingTransaction = transactionList.FirstOrDefault(a => a.Id == transaction.Id);
|
transaction.BookingDate = transaction.ValueDate;
|
||||||
|
}
|
||||||
if (existingTransaction == null)
|
|
||||||
|
var transactionList = await GetTransactionListAsync();
|
||||||
|
var idx = transactionList.FindIndex(t => t.Id == transaction.Id);
|
||||||
|
if (idx >= 0)
|
||||||
|
{
|
||||||
|
transactionList[idx] = transaction;
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
existingTransaction = new Transaction();
|
|
||||||
transactionList.Add(existingTransaction);
|
|
||||||
transaction.Id = Guid.NewGuid().ToString();
|
transaction.Id = Guid.NewGuid().ToString();
|
||||||
|
transactionList.Add(transaction);
|
||||||
}
|
}
|
||||||
transaction.Copy(existingTransaction);
|
await SaveAsync(transactionList, TRANSACTIONS);
|
||||||
Save(transactionList, TRANSACTIONS);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task<Transaction> GetTransactionAsync(string id)
|
||||||
/// Get transaction
|
|
||||||
/// </summary>
|
|
||||||
public Transaction GetTransaction(string id)
|
|
||||||
{
|
{
|
||||||
var transactions = GetTransactionList();
|
var transactions = await GetTransactionListAsync();
|
||||||
var transaction = transactions.FirstOrDefault(t => t.Id == id);
|
var stored = transactions.FirstOrDefault(t => t.Id == id);
|
||||||
|
if (stored == null)
|
||||||
// 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)
|
|
||||||
{
|
{
|
||||||
transaction.Value = transaction.Value / transaction.FxRate.Value;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return transaction;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task DeleteTransactionAsync(string id)
|
||||||
/// Delete transaction
|
|
||||||
/// </summary>
|
|
||||||
public void DeleteTransaction(string id)
|
|
||||||
{
|
{
|
||||||
var transactions = GetTransactionList();
|
var transactions = await GetTransactionListAsync();
|
||||||
transactions.RemoveAll(t => t.Id == id);
|
transactions.RemoveAll(t => t.Id == id);
|
||||||
Save(transactions, TRANSACTIONS);
|
await SaveAsync(transactions, TRANSACTIONS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
39
Schaad.Accounting.Db/RepositoryCache.cs
Normal file
39
Schaad.Accounting.Db/RepositoryCache.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
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,10 +4,11 @@
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />
|
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Schaad.Accounting.Datasets;
|
||||||
using Schaad.Accounting.Datasets.Charts;
|
using Schaad.Accounting.Datasets.Charts;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
|
|
||||||
|
|
@ -8,115 +10,135 @@ namespace Schaad.Accounting.Services
|
||||||
{
|
{
|
||||||
public class ChartService : IChartService
|
public class ChartService : IChartService
|
||||||
{
|
{
|
||||||
private readonly IAccountRepository accountRepository;
|
|
||||||
private readonly ISettingsService settingsService;
|
private readonly ISettingsService settingsService;
|
||||||
private readonly ISubclassRepository subclassRepository;
|
|
||||||
private readonly IViewService viewService;
|
private readonly IViewService viewService;
|
||||||
|
|
||||||
public ChartService(
|
public ChartService(ISettingsService settingsService, IViewService viewService)
|
||||||
ISettingsService settingsService,
|
|
||||||
IViewService viewService,
|
|
||||||
IAccountRepository accountRepository,
|
|
||||||
ISubclassRepository subclassRepository)
|
|
||||||
{
|
{
|
||||||
this.settingsService = settingsService;
|
this.settingsService = settingsService;
|
||||||
this.viewService = viewService;
|
this.viewService = viewService;
|
||||||
this.accountRepository = accountRepository;
|
|
||||||
this.subclassRepository = subclassRepository;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<DataSerie> GetExpensesPerMonth()
|
public async Task<IReadOnlyList<DataSerie>> GetExpensesPerMonthAsync()
|
||||||
{
|
{
|
||||||
var transactions = viewService.GetTransactionViewList().Where(a => a.TargetAccount.Class == ClassIds.Expenses);
|
var expenseTransactions = (await viewService.GetTransactionViewListAsync())
|
||||||
|
.Where(t => t.TargetAccount.Class == ClassIds.Expenses)
|
||||||
// More than one subclass xx
|
.ToList();
|
||||||
if (transactions.Select(t => t.TargetAccount.SubClass).Distinct().Count() > 1)
|
|
||||||
{
|
var distinctSubClasses = expenseTransactions
|
||||||
return GetSubClassExpensesPerMonth();
|
.Select(t => t.TargetAccount.SubClass)
|
||||||
}
|
.Distinct()
|
||||||
|
.Count();
|
||||||
// only one subclass xx (z.B. Mandant Mannenbach)
|
|
||||||
return GetAccountExpensesPerMonth();
|
return distinctSubClasses > 1
|
||||||
|
? await GetSubClassExpensesPerMonthAsync(expenseTransactions)
|
||||||
|
: await GetAccountExpensesPerMonthAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<DataSerie> GetSubClassExpensesPerMonth()
|
private async Task<List<DataSerie>> GetSubClassExpensesPerMonthAsync(List<TransactionDataset> expenseTransactions)
|
||||||
{
|
{
|
||||||
//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();
|
var year = settingsService.GetYear();
|
||||||
|
var maxMonth = expenseTransactions
|
||||||
|
.OrderByDescending(t => t.ValueDate)
|
||||||
|
.FirstOrDefault()?.ValueDate.Month ?? 12;
|
||||||
|
|
||||||
// Group by subclass
|
var subClassNameByNumber = (await viewService.GetAccountViewListAsync())
|
||||||
foreach (var grp in transactions.GroupBy(a => a.TargetAccount.SubClass).Select(a => new {Key = a.Key, List = a.ToList()}))
|
.GroupBy(a => a.SubClass)
|
||||||
{
|
.ToDictionary(g => g.Key, g => g.First().SubClassName);
|
||||||
// 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);
|
|
||||||
|
|
||||||
//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 List<DataSerie> GetAccountExpensesPerMonth()
|
|
||||||
{
|
|
||||||
var list = new List<DataSerie>();
|
var list = new List<DataSerie>();
|
||||||
var expensesAccounts = viewService.GetAccountViewList().Where(a => a.Class == ClassIds.Expenses);
|
foreach (var grp in expenseTransactions.GroupBy(t => t.TargetAccount.SubClass))
|
||||||
foreach (var account in expensesAccounts)
|
|
||||||
{
|
{
|
||||||
var serie = GetAccountExpensesPerMonth(account.Id, DateTime.Now.Year);
|
var groupedByMonth = grp
|
||||||
if (serie != null)
|
.GroupBy(t => t.ValueDate.Month)
|
||||||
{
|
.ToDictionary(g => g.Key, g => g.Sum(t => t.GetValue(t.TargetAccount.IsFxAccount)));
|
||||||
list.Add(serie);
|
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()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
private DataSerie GetAccountExpensesPerMonth(string accountId, int year)
|
private async Task<List<DataSerie>> GetAccountExpensesPerMonthAsync()
|
||||||
{
|
{
|
||||||
if (settingsService.TrySetYear(year))
|
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 accountList = accountRepository.GetAccountList();
|
var transactions = allTransactions.Where(t => t.TargetAccountId == account.Id).ToList();
|
||||||
var account = accountList.SingleOrDefault(a => a.Id == accountId);
|
if (transactions.Count == 0)
|
||||||
// perhaps we dont have the account for last year
|
|
||||||
if (account == null)
|
|
||||||
{
|
{
|
||||||
return null;
|
continue;
|
||||||
}
|
}
|
||||||
var transactions = viewService.GetTransactionViewList().Where(t => t.TargetAccountId == accountId).ToList();
|
|
||||||
|
|
||||||
var newestTransaction = transactions.OrderByDescending(t => t.ValueDate).FirstOrDefault();
|
var maxMonth = transactions.Max(t => t.ValueDate.Month);
|
||||||
var maxMonth = newestTransaction?.ValueDate.Month ?? 12;
|
var groupedByMonth = transactions
|
||||||
|
.GroupBy(t => t.ValueDate.Month)
|
||||||
var groupedByMonth = transactions.GroupBy(g => g.ValueDate.Month).ToDictionary(g => g.Key, g => g.ToList().Sum(s => s.Value));
|
.ToDictionary(g => g.Key, g => g.Sum(t => t.GetValue(account.IsFxAccount)));
|
||||||
EnsureEntryForEveryMonth(groupedByMonth, maxMonth);
|
EnsureEntryForEveryMonth(groupedByMonth, maxMonth);
|
||||||
|
|
||||||
settingsService.SetYear(DateTime.Now.Year);
|
list.Add(new DataSerie(
|
||||||
|
Id: account.Id,
|
||||||
return new DataSerie(
|
|
||||||
Id: accountId,
|
|
||||||
Name: account.Name,
|
Name: account.Name,
|
||||||
X: groupedByMonth.OrderBy(g => g.Key).Select(g => new DateOnly(year, g.Key, 1)).ToList(),
|
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()
|
Y: groupedByMonth.OrderBy(g => g.Key).Select(g => g.Value).ToList()
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
return list;
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureEntryForEveryMonth(Dictionary<int, decimal> values, int maxMonth = 12)
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
for (int i = 1; i <= maxMonth; i++)
|
for (int i = 1; i <= maxMonth; i++)
|
||||||
{
|
{
|
||||||
|
|
@ -127,4 +149,4 @@ namespace Schaad.Accounting.Services
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ namespace Schaad.Accounting.Services
|
||||||
|
|
||||||
public decimal ConvertCurrency(decimal amount, string fromCurrency, string toCurrency, string fixerIoApiKey)
|
public decimal ConvertCurrency(decimal amount, string fromCurrency, string toCurrency, string fixerIoApiKey)
|
||||||
{
|
{
|
||||||
if (currencies.Contains(fromCurrency) && currencies.Contains(fromCurrency))
|
if (currencies.Contains(fromCurrency) && currencies.Contains(toCurrency))
|
||||||
{
|
{
|
||||||
return amount;
|
return amount;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Schaad.Accounting.Datasets;
|
using Schaad.Accounting.Datasets;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Finance.Api;
|
using Schaad.Finance.Api;
|
||||||
|
|
@ -18,6 +19,7 @@ namespace Schaad.Accounting.Services
|
||||||
private readonly ISettingsService settingsService;
|
private readonly ISettingsService settingsService;
|
||||||
private readonly IAccountStatementService accountStatementService;
|
private readonly IAccountStatementService accountStatementService;
|
||||||
private readonly ICreditCardStatementService creditCardStatementService;
|
private readonly ICreditCardStatementService creditCardStatementService;
|
||||||
|
private readonly ILogger<FileService> logger;
|
||||||
|
|
||||||
public FileService(
|
public FileService(
|
||||||
ISettingsService settingsService,
|
ISettingsService settingsService,
|
||||||
|
|
@ -25,7 +27,8 @@ namespace Schaad.Accounting.Services
|
||||||
ITransactionRepository transactionsRepository,
|
ITransactionRepository transactionsRepository,
|
||||||
IBankTransactionRepository bankTransactionRepository,
|
IBankTransactionRepository bankTransactionRepository,
|
||||||
IAccountStatementService accountStatementService,
|
IAccountStatementService accountStatementService,
|
||||||
ICreditCardStatementService creditCardStatementService)
|
ICreditCardStatementService creditCardStatementService,
|
||||||
|
ILogger<FileService> logger)
|
||||||
{
|
{
|
||||||
this.settingsService = settingsService;
|
this.settingsService = settingsService;
|
||||||
this.accountRepository = accountRepository;
|
this.accountRepository = accountRepository;
|
||||||
|
|
@ -33,6 +36,7 @@ namespace Schaad.Accounting.Services
|
||||||
this.bankTransactionRepository = bankTransactionRepository;
|
this.bankTransactionRepository = bankTransactionRepository;
|
||||||
this.accountStatementService = accountStatementService;
|
this.accountStatementService = accountStatementService;
|
||||||
this.creditCardStatementService = creditCardStatementService;
|
this.creditCardStatementService = creditCardStatementService;
|
||||||
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Backup()
|
public string Backup()
|
||||||
|
|
@ -44,19 +48,21 @@ namespace Schaad.Accounting.Services
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload account statement file (mt940, camt053)
|
// Upload account statement file (mt940, camt053)
|
||||||
// http://www.mikesdotnetting.com/article/288/asp-net-5-uploading-files-with-asp-net-mvc-6
|
public async Task<IReadOnlyList<MessageDataset>> ImportAccountStatementFileAsync(string filePath)
|
||||||
public IReadOnlyList<MessageDataset> ImportAccountStatementFile(string filePath)
|
|
||||||
{
|
{
|
||||||
|
logger.LogInformation("Importing account statement file {FilePath}", filePath);
|
||||||
|
|
||||||
var messages = new List<MessageDataset>();
|
var messages = new List<MessageDataset>();
|
||||||
var accountStatementResults = accountStatementService.ReadFile(filePath, Encoding.UTF8);
|
var accountStatementResults = accountStatementService.ReadFile(filePath, Encoding.UTF8);
|
||||||
|
|
||||||
var accountList = accountRepository.GetAccountList().Select(a => a.BankAccountNumber).ToList();
|
var accountList = (await accountRepository.GetAccountListAsync()).Select(a => a.BankAccountNumber).ToList();
|
||||||
foreach (var accountStatementResult in accountStatementResults)
|
foreach (var accountStatementResult in accountStatementResults)
|
||||||
{
|
{
|
||||||
var account = accountStatementResult.AccountStatement;
|
var account = accountStatementResult.AccountStatement;
|
||||||
|
|
||||||
if (accountStatementResult.IsSuccess == false)
|
if (accountStatementResult.IsSuccess == false)
|
||||||
{
|
{
|
||||||
|
logger.LogError("Statement parse failed for account {AccountNumber}: {Error}", account.AccountNumber, accountStatementResult.Error);
|
||||||
messages.Add(new MessageDataset($"Account {account.AccountNumber} NICHT importiert: {accountStatementResult.Error}", MessageStatus.Error));
|
messages.Add(new MessageDataset($"Account {account.AccountNumber} NICHT importiert: {accountStatementResult.Error}", MessageStatus.Error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,8 +91,11 @@ namespace Schaad.Accounting.Services
|
||||||
Creditor = t.Creditor
|
Creditor = t.Creditor
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
var count = bankTransactionRepository.SaveBankTransactionList(account.AccountNumber, transactionsThisYear);
|
var count = await bankTransactionRepository.SaveBankTransactionListAsync(account.AccountNumber, transactionsThisYear);
|
||||||
accountRepository.SaveBankAccountBalance(account.AccountNumber, (decimal)account.EndBalance.Value);
|
await accountRepository.SaveBankAccountBalanceAsync(account.AccountNumber, (decimal)account.EndBalance.Value);
|
||||||
|
|
||||||
|
logger.LogInformation("Imported {Imported} of {Total} transactions for account {AccountNumber}",
|
||||||
|
count, account.Transactions.Count, account.AccountNumber);
|
||||||
|
|
||||||
var status = count == account.Transactions.Count ? MessageStatus.Success : MessageStatus.Info;
|
var status = count == account.Transactions.Count ? MessageStatus.Success : MessageStatus.Info;
|
||||||
message.Add($"{count} von {account.Transactions.Count} Transaktion(en) importiert.", status);
|
message.Add($"{count} von {account.Transactions.Count} Transaktion(en) importiert.", status);
|
||||||
|
|
@ -100,6 +109,8 @@ namespace Schaad.Accounting.Services
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
logger.LogWarning("Skipping account {AccountNumber}: not part of the currently selected mandator ({Skipped} transactions ignored)",
|
||||||
|
account.AccountNumber, account.Transactions.Count);
|
||||||
message.Add($"Falscher Mandant: {account.Transactions.Count} Transaktion(en) nicht importiert.", MessageStatus.Warning);
|
message.Add($"Falscher Mandant: {account.Transactions.Count} Transaktion(en) nicht importiert.", MessageStatus.Warning);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -113,11 +124,11 @@ namespace Schaad.Accounting.Services
|
||||||
return creditCardTransactions;
|
return creditCardTransactions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public byte[] GetTransactionListCsv(string accountId)
|
public async Task<byte[]> GetTransactionListCsvAsync(string accountId)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
var account = accountRepository.GetAccount(accountId);
|
var account = await accountRepository.GetAccountAsync(accountId);
|
||||||
var transactions = transactionsRepository.GetTransactionList()
|
var transactions = (await transactionsRepository.GetTransactionListAsync())
|
||||||
.Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId)
|
.Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId)
|
||||||
.OrderBy(t => t.BookingDate)
|
.OrderBy(t => t.BookingDate)
|
||||||
.ThenBy(t => t.ValueDate)
|
.ThenBy(t => t.ValueDate)
|
||||||
|
|
@ -130,28 +141,22 @@ namespace Schaad.Accounting.Services
|
||||||
{
|
{
|
||||||
var credit = "";
|
var credit = "";
|
||||||
var debit = "";
|
var debit = "";
|
||||||
|
var signedValue = trx.Value;
|
||||||
|
|
||||||
if (trx.OriginAccountId == accountId)
|
if (trx.OriginAccountId == accountId)
|
||||||
{
|
{
|
||||||
debit = ToFormattedString(trx.Value);
|
debit = trx.Value.ToFormattedString();
|
||||||
trx.Value *= -1;
|
signedValue = -trx.Value;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
credit = ToFormattedString(trx.Value);
|
credit = trx.Value.ToFormattedString();
|
||||||
}
|
}
|
||||||
balance += trx.Value;
|
balance += signedValue;
|
||||||
sb.AppendLine($"{trx.BookingDate:dd.MM.yyyy};{trx.ValueDate:dd.MM.yyyy};{trx.Text};{debit};{credit};{ToFormattedString(balance)}");
|
sb.AppendLine($"{trx.BookingDate:dd.MM.yyyy};{trx.ValueDate:dd.MM.yyyy};{trx.Text};{debit};{credit};{balance.ToFormattedString()}");
|
||||||
}
|
}
|
||||||
var fileBytes = Encoding.GetEncoding("ISO-8859-1").GetBytes(sb.ToString());
|
var fileBytes = Encoding.GetEncoding("ISO-8859-1").GetBytes(sb.ToString());
|
||||||
return fileBytes;
|
return fileBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ToFormattedString(decimal value)
|
|
||||||
{
|
|
||||||
var culture = new CultureInfo("de-CH");
|
|
||||||
culture.NumberFormat.NumberGroupSeparator = "'";
|
|
||||||
return value.ToString("#,0.00", culture);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
Schaad.Accounting.Services/FxConverter.cs
Normal file
21
Schaad.Accounting.Services/FxConverter.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
using Schaad.Accounting.Datasets;
|
||||||
|
using Schaad.Accounting.Interfaces;
|
||||||
|
using Schaad.Finance.Api;
|
||||||
|
|
||||||
|
namespace Schaad.Accounting.Services
|
||||||
|
{
|
||||||
|
public class FxConverter : IFxConverter
|
||||||
|
{
|
||||||
|
private readonly IFxService fxService;
|
||||||
|
private readonly SettingsDataset settings;
|
||||||
|
|
||||||
|
public FxConverter(IFxService fxService, SettingsDataset settings)
|
||||||
|
{
|
||||||
|
this.fxService = fxService;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public decimal ConvertToChf(decimal amount, string fromCurrency)
|
||||||
|
=> fxService.ConvertCurrency(amount, fromCurrency, "CHF", settings.FixerIoApiKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
Schaad.Accounting.Services/Interfaces/IChartService.cs
Normal file
15
Schaad.Accounting.Services/Interfaces/IChartService.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
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,4 +1,5 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Schaad.Accounting.Datasets;
|
using Schaad.Accounting.Datasets;
|
||||||
using Schaad.Finance.Api;
|
using Schaad.Finance.Api;
|
||||||
using Schaad.Finance.Api.Datasets;
|
using Schaad.Finance.Api.Datasets;
|
||||||
|
|
@ -8,8 +9,8 @@ namespace Schaad.Accounting.Interfaces
|
||||||
public interface IFileService
|
public interface IFileService
|
||||||
{
|
{
|
||||||
string Backup();
|
string Backup();
|
||||||
IReadOnlyList<MessageDataset> ImportAccountStatementFile(string filePath);
|
Task<IReadOnlyList<MessageDataset>> ImportAccountStatementFileAsync(string filePath);
|
||||||
IReadOnlyList<CreditCardTransaction> ImportCreditCardStatementFile(CreditCardProvider creditCardProvider, string filePath);
|
IReadOnlyList<CreditCardTransaction> ImportCreditCardStatementFile(CreditCardProvider creditCardProvider, string filePath);
|
||||||
byte[] GetTransactionListCsv(string accountId);
|
Task<byte[]> GetTransactionListCsvAsync(string accountId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
11
Schaad.Accounting.Services/Interfaces/IFxConverter.cs
Normal file
11
Schaad.Accounting.Services/Interfaces/IFxConverter.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
namespace Schaad.Accounting.Interfaces
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Thin wrapper over the vendor <c>IFxService</c> that hides the FixerIo API key
|
||||||
|
/// and the base currency, so callers just ask "convert this amount to CHF".
|
||||||
|
/// </summary>
|
||||||
|
public interface IFxConverter
|
||||||
|
{
|
||||||
|
decimal ConvertToChf(decimal amount, string fromCurrency);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
Schaad.Accounting.Services/Interfaces/IViewService.cs
Normal file
32
Schaad.Accounting.Services/Interfaces/IViewService.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
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>
|
<ItemGroup>
|
||||||
<PackageReference Include="FreeSpire.PDF" Version="10.2.0" />
|
<PackageReference Include="FreeSpire.PDF" Version="10.2.0" />
|
||||||
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />
|
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,13 @@ namespace Schaad.Accounting.Services
|
||||||
public class SettingsService : ISettingsService
|
public class SettingsService : ISettingsService
|
||||||
{
|
{
|
||||||
private readonly SettingsDataset settingsDataset;
|
private readonly SettingsDataset settingsDataset;
|
||||||
private string mandator = "Claudio Schaad";
|
private string mandator;
|
||||||
private int year = DateTime.Now.Year;
|
private int year = DateTime.Now.Year;
|
||||||
|
|
||||||
public SettingsService(SettingsDataset settingsDataset)
|
public SettingsService(SettingsDataset settingsDataset)
|
||||||
{
|
{
|
||||||
this.settingsDataset = settingsDataset;
|
this.settingsDataset = settingsDataset;
|
||||||
|
this.mandator = settingsDataset.DefaultMandator;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int GetYear()
|
public int GetYear()
|
||||||
|
|
@ -30,7 +31,7 @@ namespace Schaad.Accounting.Services
|
||||||
|
|
||||||
public bool TrySetYear(int year)
|
public bool TrySetYear(int year)
|
||||||
{
|
{
|
||||||
var oldYear = year;
|
var oldYear = this.year;
|
||||||
SetYear(year);
|
SetYear(year);
|
||||||
if (Directory.GetFiles(GetDbPath()).Any() == false)
|
if (Directory.GetFiles(GetDbPath()).Any() == false)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Schaad.Accounting.Datasets;
|
using Schaad.Accounting.Datasets;
|
||||||
using Schaad.Accounting.Datasets.Reports;
|
using Schaad.Accounting.Datasets.Reports;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
using Schaad.Finance.Api;
|
|
||||||
using Schaad.Finance.Api.Datasets;
|
using Schaad.Finance.Api.Datasets;
|
||||||
|
|
||||||
namespace Schaad.Accounting.Services
|
namespace Schaad.Accounting.Services
|
||||||
|
|
@ -17,8 +18,8 @@ namespace Schaad.Accounting.Services
|
||||||
private readonly IBookingRuleRepository bookingRuleRepository;
|
private readonly IBookingRuleRepository bookingRuleRepository;
|
||||||
private readonly ISubclassRepository subclassRepository;
|
private readonly ISubclassRepository subclassRepository;
|
||||||
private readonly ITransactionRepository transactionRepository;
|
private readonly ITransactionRepository transactionRepository;
|
||||||
private readonly IFxService fxService;
|
private readonly IFxConverter fxConverter;
|
||||||
private readonly ISettingsService settingsService;
|
private readonly ILogger<ViewService> logger;
|
||||||
|
|
||||||
public ViewService(
|
public ViewService(
|
||||||
IAccountRepository accountRepository,
|
IAccountRepository accountRepository,
|
||||||
|
|
@ -26,200 +27,165 @@ namespace Schaad.Accounting.Services
|
||||||
ITransactionRepository transactionRepository,
|
ITransactionRepository transactionRepository,
|
||||||
ISubclassRepository subclassRepository,
|
ISubclassRepository subclassRepository,
|
||||||
IBookingRuleRepository bookingRuleRepository,
|
IBookingRuleRepository bookingRuleRepository,
|
||||||
IFxService fxService,
|
IFxConverter fxConverter,
|
||||||
ISettingsService settingsService)
|
ILogger<ViewService> logger)
|
||||||
{
|
{
|
||||||
this.accountRepository = accountRepository;
|
this.accountRepository = accountRepository;
|
||||||
this.bankTransactionRepository = bankTransactionRepository;
|
this.bankTransactionRepository = bankTransactionRepository;
|
||||||
this.transactionRepository = transactionRepository;
|
this.transactionRepository = transactionRepository;
|
||||||
this.subclassRepository = subclassRepository;
|
this.subclassRepository = subclassRepository;
|
||||||
this.bookingRuleRepository = bookingRuleRepository;
|
this.bookingRuleRepository = bookingRuleRepository;
|
||||||
this.fxService = fxService;
|
this.fxConverter = fxConverter;
|
||||||
this.settingsService = settingsService;
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task<AccountDataset> GetAccountViewAsync(string id)
|
||||||
/// Load accounts
|
|
||||||
/// </summary>
|
|
||||||
public AccountDataset GetAccountView(string id)
|
|
||||||
{
|
{
|
||||||
var accounts = GetAccountViewList();
|
var accounts = await GetAccountViewListAsync();
|
||||||
return accounts.Single(t => t.Id == id);
|
return accounts.Single(t => t.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task<List<AccountDataset>> GetAccountViewListAsync()
|
||||||
/// Load accounts and calculates their balances
|
|
||||||
/// </summary>
|
|
||||||
public List<AccountDataset> GetAccountViewList()
|
|
||||||
{
|
{
|
||||||
var accounts = accountRepository.GetAccountList();
|
var accounts = await accountRepository.GetAccountListAsync();
|
||||||
var transactionList = GetTransactionViewList();
|
var transactionList = await GetTransactionViewListAsync();
|
||||||
var subClassNameByNumber = subclassRepository.GetSubClassList().ToDictionary(s => s.Number, s => s.Name);
|
var subClassNameByNumber = (await subclassRepository.GetSubClassListAsync()).ToDictionary(s => s.Number, s => s.Name);
|
||||||
var settings = settingsService.GetSettings();
|
|
||||||
|
|
||||||
var accountList = accounts.Select(
|
// Group transactions by account so per-account balance is O(k) instead of O(M).
|
||||||
a =>
|
var creditsByAccount = transactionList.GroupBy(t => t.TargetAccountId).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
new AccountDataset(
|
var debitsByAccount = transactionList.GroupBy(t => t.OriginAccountId).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
account: a,
|
|
||||||
balance: GetBalanceInAccountCurrency(a, transactionList),
|
var accountList = accounts.Select(a =>
|
||||||
balanceCHF: GetCurrentBalanceInCHF(a, transactionList),
|
{
|
||||||
startBalanceCHF: fxService.ConvertCurrency(a.StartBalance, a.Currency, "CHF", settings.FixerIoApiKey),
|
var balance = ComputeBalance(a, creditsByAccount, debitsByAccount);
|
||||||
className: subclassRepository.GetClass(a.Class),
|
return new AccountDataset(
|
||||||
subClassName: subClassNameByNumber[a.SubClass]
|
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]);
|
||||||
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return accountList.OrderBy(a => a.Number).ToList();
|
return accountList.OrderBy(a => a.Number).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public BalanceDataset GetBalanceView()
|
private static decimal ComputeBalance(
|
||||||
|
Account account,
|
||||||
|
Dictionary<string, List<TransactionDataset>> creditsByAccount,
|
||||||
|
Dictionary<string, List<TransactionDataset>> debitsByAccount)
|
||||||
{
|
{
|
||||||
var accountList = GetAccountViewList();
|
var balance = account.StartBalance;
|
||||||
var balanceView = new BalanceDataset(
|
if (creditsByAccount.TryGetValue(account.Id, out var credits))
|
||||||
activaAccountList: accountList.Where(m => m.Class == ClassIds.Activa).ToList(),
|
{
|
||||||
passivaAccountList: accountList.Where(m => m.Class == ClassIds.Passiva).ToList(),
|
balance += credits.Sum(t => t.GetValue(account.IsFxAccount));
|
||||||
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))
|
if (debitsByAccount.TryGetValue(account.Id, out var debits))
|
||||||
);
|
{
|
||||||
|
balance -= debits.Sum(t => t.GetValue(account.IsFxAccount));
|
||||||
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;
|
return balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
private decimal GetCurrentBalanceInCHF(Account a, List<TransactionDataset> transactionList)
|
public async Task<BalanceDataset> GetBalanceViewAsync()
|
||||||
{
|
{
|
||||||
var settings = settingsService.GetSettings();
|
var accountList = await GetAccountViewListAsync();
|
||||||
var balanceInAccountCurrency = GetBalanceInAccountCurrency(a, transactionList);
|
return new BalanceDataset(
|
||||||
var balanceInChf = fxService.ConvertCurrency(balanceInAccountCurrency, a.Currency, "CHF", settings.FixerIoApiKey);
|
ActivaAccountList: accountList.Where(m => m.Class == ClassIds.Activa).ToList(),
|
||||||
return balanceInChf;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get transaction list with the origin and target account for each transaction
|
/// Get transaction list with the origin and target account for each transaction
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<TransactionDataset> GetTransactionViewList()
|
public async Task<List<TransactionDataset>> GetTransactionViewListAsync()
|
||||||
{
|
{
|
||||||
var accountList = accountRepository.GetAccountList();
|
var accountsById = (await accountRepository.GetAccountListAsync()).ToDictionary(a => a.Id);
|
||||||
var transactionList = transactionRepository.GetTransactionList();
|
var transactionList = await transactionRepository.GetTransactionListAsync();
|
||||||
|
|
||||||
var transactionViewList = transactionList.Select(
|
return transactionList.Select(t =>
|
||||||
t =>
|
new TransactionDataset(
|
||||||
new TransactionDataset(
|
t,
|
||||||
t,
|
accountsById[t.OriginAccountId],
|
||||||
accountList.Single(a => a.Id == t.OriginAccountId),
|
accountsById[t.TargetAccountId]))
|
||||||
accountList.Single(a => a.Id == t.TargetAccountId))
|
|
||||||
)
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return transactionViewList;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get transaction list with the origin and target account for each transaction
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<TransactionDataset> GetTransactionViewList(string accountId)
|
public async Task<List<TransactionDataset>> GetTransactionViewListAsync(string accountId)
|
||||||
{
|
{
|
||||||
var accountList = accountRepository.GetAccountList();
|
var accountsById = (await accountRepository.GetAccountListAsync()).ToDictionary(a => a.Id);
|
||||||
var transactionList = transactionRepository.GetTransactionList().Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId);
|
var transactionList = (await transactionRepository.GetTransactionListAsync())
|
||||||
|
.Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId);
|
||||||
|
|
||||||
var transactionViewList = transactionList.Select(
|
return transactionList.Select(t =>
|
||||||
t =>
|
new TransactionDataset(
|
||||||
new TransactionDataset(
|
t,
|
||||||
Prepare(t),
|
accountsById[t.OriginAccountId],
|
||||||
accountList.Single(a => a.Id == t.OriginAccountId),
|
accountsById[t.TargetAccountId]))
|
||||||
accountList.Single(a => a.Id == t.TargetAccountId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.ToList();
|
.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>
|
/// <summary>
|
||||||
/// Get booking rules with their account
|
/// Get booking rules with their account
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<BookingRuleDataset> GetBookingRuleViewList()
|
public async Task<List<BookingRuleDataset>> GetBookingRuleViewListAsync()
|
||||||
{
|
{
|
||||||
var accountList = accountRepository.GetAccountList();
|
var accountsById = (await accountRepository.GetAccountListAsync()).ToDictionary(a => a.Id);
|
||||||
var bookinRuleList = bookingRuleRepository.GetBookingRuleList();
|
var bookingRules = await bookingRuleRepository.GetBookingRuleListAsync();
|
||||||
|
|
||||||
return bookinRuleList.Select(
|
return bookingRules.Select(t =>
|
||||||
t =>
|
new BookingRuleDataset(t, accountsById[t.AccountId].Name))
|
||||||
new BookingRuleDataset(
|
|
||||||
t,
|
|
||||||
accountList.Single(a => a.Id == t.AccountId).Name
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Load open bank transactions
|
/// Load open bank transactions
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<BankTransaction> GetOpenBankTransactionList()
|
public async Task<List<BankTransaction>> GetOpenBankTransactionListAsync()
|
||||||
{
|
{
|
||||||
var transactions = transactionRepository
|
var transactions = (await transactionRepository.GetTransactionListAsync())
|
||||||
.GetTransactionList()
|
|
||||||
.Where(t => string.IsNullOrEmpty(t.BankTransactionId) == false)
|
.Where(t => string.IsNullOrEmpty(t.BankTransactionId) == false)
|
||||||
.Select(t => t.BankTransactionId);
|
.Select(t => t.BankTransactionId);
|
||||||
|
|
||||||
var bankTransactions = bankTransactionRepository
|
var bankTransactions = (await bankTransactionRepository.GetBankTransactionListAsync())
|
||||||
.GetBankTransactionList()
|
|
||||||
.Where(b => b.Ignore == false && transactions.Contains(b.Id) == false)
|
.Where(b => b.Ignore == false && transactions.Contains(b.Id) == false)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return bankTransactions;
|
return bankTransactions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Transaction> MatchOpenBankTransactions()
|
public async Task<List<Transaction>> MatchOpenBankTransactionsAsync()
|
||||||
{
|
{
|
||||||
var newTransactionList = new List<Transaction>();
|
var newTransactionList = new List<Transaction>();
|
||||||
var transactions = transactionRepository.GetTransactionList();
|
var transactions = await transactionRepository.GetTransactionListAsync();
|
||||||
var accounts = accountRepository.GetAccountList();
|
var accounts = await accountRepository.GetAccountListAsync();
|
||||||
var bookingRules = bookingRuleRepository.GetBookingRuleList();
|
var bookingRules = await bookingRuleRepository.GetBookingRuleListAsync();
|
||||||
var bankTransactions = GetOpenBankTransactionList().OrderBy(t => t.ValueDate);
|
var bankTransactions = (await GetOpenBankTransactionListAsync()).OrderBy(t => t.ValueDate).ToList();
|
||||||
foreach (var bankTransaction in bankTransactions)
|
foreach (var bankTransaction in bankTransactions)
|
||||||
{
|
{
|
||||||
var trx = new Transaction(bankTransaction, accounts);
|
var trx = new Transaction(bankTransaction, accounts);
|
||||||
|
|
@ -228,14 +194,18 @@ namespace Schaad.Accounting.Services
|
||||||
MatchBankTransactionBySameValueLastMonth(bankTransaction, trx, transactions);
|
MatchBankTransactionBySameValueLastMonth(bankTransaction, trx, transactions);
|
||||||
newTransactionList.Add(trx);
|
newTransactionList.Add(trx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var matched = newTransactionList.Count(t => string.IsNullOrEmpty(t.Text) == false);
|
||||||
|
logger.LogInformation("Matched {Matched} of {Total} open bank transactions", matched, bankTransactions.Count);
|
||||||
|
|
||||||
return newTransactionList;
|
return newTransactionList;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Transaction> MatchCreditCardTransactions(string bankTransactionId, IReadOnlyList<CreditCardTransaction> creditCardTransactions)
|
public async Task<List<Transaction>> MatchCreditCardTransactionsAsync(string bankTransactionId, IReadOnlyList<CreditCardTransaction> creditCardTransactions)
|
||||||
{
|
{
|
||||||
var accounts = accountRepository.GetAccountList();
|
var accounts = await accountRepository.GetAccountListAsync();
|
||||||
var bankTrx = bankTransactionRepository.GetBankTransaction(bankTransactionId);
|
var bankTrx = await bankTransactionRepository.GetBankTransactionAsync(bankTransactionId);
|
||||||
var bookingRules = bookingRuleRepository.GetBookingRuleList();
|
var bookingRules = await bookingRuleRepository.GetBookingRuleListAsync();
|
||||||
|
|
||||||
var trxList = new List<Transaction>();
|
var trxList = new List<Transaction>();
|
||||||
foreach (var creditCardTransaction in creditCardTransactions)
|
foreach (var creditCardTransaction in creditCardTransactions)
|
||||||
|
|
@ -363,4 +333,4 @@ namespace Schaad.Accounting.Services
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
113
Schaad.Accounting.Tests/AccountRepositoryTestShould.cs
Normal file
113
Schaad.Accounting.Tests/AccountRepositoryTestShould.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
317
Schaad.Accounting.Tests/ChartServiceTestShould.cs
Normal file
317
Schaad.Accounting.Tests/ChartServiceTestShould.cs
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
98
Schaad.Accounting.Tests/FileServiceTestShould.cs
Normal file
98
Schaad.Accounting.Tests/FileServiceTestShould.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Schaad.Accounting.Tests/FormattingTestShould.cs
Normal file
41
Schaad.Accounting.Tests/FormattingTestShould.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
Schaad.Accounting.Tests/FxConverterTestShould.cs
Normal file
34
Schaad.Accounting.Tests/FxConverterTestShould.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
using NSubstitute;
|
||||||
|
using Schaad.Accounting.Datasets;
|
||||||
|
using Schaad.Accounting.Services;
|
||||||
|
using Schaad.Finance.Api;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace Schaad.Accounting.Tests;
|
||||||
|
|
||||||
|
public class FxConverterTestShould
|
||||||
|
{
|
||||||
|
private readonly IFxService fxService = Substitute.For<IFxService>();
|
||||||
|
private readonly SettingsDataset settings = new() { DataPath = "", FixerIoApiKey = "test-key" };
|
||||||
|
|
||||||
|
private FxConverter BuildConverter() => new(fxService, settings);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DelegateToFxServiceWithChfAsTargetAndConfiguredApiKeyWhenConverting()
|
||||||
|
{
|
||||||
|
fxService.ConvertCurrency(100m, "USD", "CHF", "test-key").Returns(90m);
|
||||||
|
|
||||||
|
var result = BuildConverter().ConvertToChf(100m, "USD");
|
||||||
|
|
||||||
|
result.ShouldBe(90m);
|
||||||
|
fxService.Received(1).ConvertCurrency(100m, "USD", "CHF", "test-key");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PassNegativeAmountsThroughWhenConverting()
|
||||||
|
{
|
||||||
|
fxService.ConvertCurrency(-50m, "EUR", "CHF", Arg.Any<string>()).Returns(-48m);
|
||||||
|
|
||||||
|
BuildConverter().ConvertToChf(-50m, "EUR").ShouldBe(-48m);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
Schaad.Accounting.Tests/RepositoryCacheTestShould.cs
Normal file
58
Schaad.Accounting.Tests/RepositoryCacheTestShould.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
35
Schaad.Accounting.Tests/Schaad.Accounting.Tests.csproj
Normal file
35
Schaad.Accounting.Tests/Schaad.Accounting.Tests.csproj
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<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>
|
||||||
85
Schaad.Accounting.Tests/TransactionDatasetTestShould.cs
Normal file
85
Schaad.Accounting.Tests/TransactionDatasetTestShould.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
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("");
|
||||||
|
}
|
||||||
|
}
|
||||||
173
Schaad.Accounting.Tests/TransactionRepositoryTestShould.cs
Normal file
173
Schaad.Accounting.Tests/TransactionRepositoryTestShould.cs
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
200
Schaad.Accounting.Tests/ViewServiceTestShould.cs
Normal file
200
Schaad.Accounting.Tests/ViewServiceTestShould.cs
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
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,4 +1,6 @@
|
||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
<FluentLayout>
|
<FluentLayout>
|
||||||
<FluentHeader Class="hidePrint">
|
<FluentHeader Class="hidePrint">
|
||||||
|
|
@ -9,7 +11,24 @@
|
||||||
<FluentBodyContent Class="body-content">
|
<FluentBodyContent Class="body-content">
|
||||||
<FluentMessageBarProvider Section="MESSAGES_TOP" @rendermode="InteractiveServer" />
|
<FluentMessageBarProvider Section="MESSAGES_TOP" @rendermode="InteractiveServer" />
|
||||||
<div class="content">
|
<div class="content">
|
||||||
@Body
|
<ErrorBoundary @ref="errorBoundary">
|
||||||
|
<ChildContent>
|
||||||
|
@Body
|
||||||
|
</ChildContent>
|
||||||
|
<ErrorContent>
|
||||||
|
<FluentStack Orientation="Orientation.Vertical" Style="padding: 2rem; max-width: 640px;">
|
||||||
|
<FluentMessageBar Intent="MessageIntent.Error"
|
||||||
|
Title="Ein Fehler ist aufgetreten"
|
||||||
|
AllowDismiss="false">
|
||||||
|
Diese Seite konnte nicht angezeigt werden. Details stehen im Server-Log.
|
||||||
|
</FluentMessageBar>
|
||||||
|
<FluentStack Orientation="Orientation.Horizontal">
|
||||||
|
<FluentButton Appearance="Appearance.Neutral" OnClick="Recover">Neu versuchen</FluentButton>
|
||||||
|
<FluentAnchor Href="/" Appearance="Appearance.Neutral">Zur Startseite</FluentAnchor>
|
||||||
|
</FluentStack>
|
||||||
|
</FluentStack>
|
||||||
|
</ErrorContent>
|
||||||
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</FluentBodyContent>
|
</FluentBodyContent>
|
||||||
</FluentStack>
|
</FluentStack>
|
||||||
|
|
@ -28,4 +47,23 @@
|
||||||
An unhandled error has occurred.
|
An unhandled error has occurred.
|
||||||
<a href="." class="reload">Reload</a>
|
<a href="." class="reload">Reload</a>
|
||||||
<span class="dismiss">🗙</span>
|
<span class="dismiss">🗙</span>
|
||||||
</div>
|
</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;
|
||||||
|
}
|
||||||
9
Schaad.Accounting.UI/Components/Loading.razor
Normal file
9
Schaad.Accounting.UI/Components/Loading.razor
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
@* 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,10 +3,13 @@
|
||||||
|
|
||||||
<PageTitle>Konten</PageTitle>
|
<PageTitle>Konten</PageTitle>
|
||||||
|
|
||||||
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neues Konto hinzufügen</FluentButton>
|
<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>
|
||||||
<FluentPaginator State="@pagination" SummaryTemplate="@template" />
|
<FluentPaginator State="@pagination" SummaryTemplate="@template" />
|
||||||
|
|
||||||
<FluentDataGrid Items="@accountQueryable" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
|
<FluentDataGrid Items="@Filtered" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
|
||||||
<PropertyColumn Property="@(p => p.Class)" Sortable="true" Width="100px" />
|
<PropertyColumn Property="@(p => p.Class)" Sortable="true" Width="100px" />
|
||||||
<PropertyColumn Property="@(p => p.SubClass)" Sortable="true" Width="140px" />
|
<PropertyColumn Property="@(p => p.SubClass)" Sortable="true" Width="140px" />
|
||||||
<PropertyColumn Property="@(p => p.Number)" Sortable="true" IsDefaultSortColumn="true" Width="140px" />
|
<PropertyColumn Property="@(p => p.Number)" Sortable="true" IsDefaultSortColumn="true" Width="140px" />
|
||||||
|
|
@ -16,10 +19,10 @@
|
||||||
<PropertyColumn Property="@(p => p.StartBalance)" Format="C2"/>
|
<PropertyColumn Property="@(p => p.StartBalance)" Format="C2"/>
|
||||||
<PropertyColumn Property="@(p => p.LastBankBalance)" Format="C2" />
|
<PropertyColumn Property="@(p => p.LastBankBalance)" Format="C2" />
|
||||||
<TemplateColumn Align="Align.Center" Width="50px">
|
<TemplateColumn Align="Align.Center" Width="50px">
|
||||||
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" OnClick="@(() => DeleteAsync(context.Id))" />
|
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" title="Löschen" aria-label="Löschen" OnClick="@(() => DeleteAsync(context.Id))" />
|
||||||
</TemplateColumn>
|
</TemplateColumn>
|
||||||
<TemplateColumn Align="Align.Center" Width="50px">
|
<TemplateColumn Align="Align.Center" Width="50px">
|
||||||
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" OnClick="@(() => EditAsync(context.Id))" />
|
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" title="Bearbeiten" aria-label="Bearbeiten" OnClick="@(() => EditAsync(context.Id))" />
|
||||||
</TemplateColumn>
|
</TemplateColumn>
|
||||||
</FluentDataGrid>
|
</FluentDataGrid>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.FluentUI.AspNetCore.Components;
|
using Microsoft.FluentUI.AspNetCore.Components;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
@ -8,20 +8,32 @@ namespace Schaad.Accounting.UI.Components.Pages;
|
||||||
|
|
||||||
public partial class Accounts : ComponentBase
|
public partial class Accounts : ComponentBase
|
||||||
{
|
{
|
||||||
[Inject]
|
[Inject]
|
||||||
private IAccountRepository accountRepository { get; set; } = null!;
|
private IAccountRepository accountRepository { get; set; } = null!;
|
||||||
|
|
||||||
[Inject]
|
[Inject]
|
||||||
private IDialogService dialogService { get; set; } = null!;
|
private IDialogService dialogService { get; set; } = null!;
|
||||||
|
|
||||||
private IQueryable<Account>? accountQueryable;
|
[Inject]
|
||||||
|
private IToastService toastService { get; set; } = null!;
|
||||||
protected override Task OnInitializedAsync()
|
|
||||||
{
|
private IReadOnlyList<Account> source = [];
|
||||||
accountQueryable = accountRepository.GetAccountList().AsQueryable();
|
private string filter = "";
|
||||||
return base.OnInitializedAsync();
|
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
source = await accountRepository.GetAccountListAsync();
|
||||||
|
await base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddAsync()
|
private async Task AddAsync()
|
||||||
{
|
{
|
||||||
var data = new Account();
|
var data = new Account();
|
||||||
|
|
@ -36,13 +48,14 @@ public partial class Accounts : ComponentBase
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled && result.Data != null)
|
if (!result.Cancelled && result.Data != null)
|
||||||
{
|
{
|
||||||
accountQueryable = accountRepository.GetAccountList().AsQueryable();
|
source = await accountRepository.GetAccountListAsync();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Konto gespeichert", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EditAsync(string id)
|
private async Task EditAsync(string id)
|
||||||
{
|
{
|
||||||
var data = accountRepository.GetAccount(id);
|
var data = await accountRepository.GetAccountAsync(id);
|
||||||
var dialog = await dialogService.ShowDialogAsync<AccountDialog>(data, new DialogParameters()
|
var dialog = await dialogService.ShowDialogAsync<AccountDialog>(data, new DialogParameters()
|
||||||
{
|
{
|
||||||
Height = "500px",
|
Height = "500px",
|
||||||
|
|
@ -54,19 +67,21 @@ public partial class Accounts : ComponentBase
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled && result.Data != null)
|
if (!result.Cancelled && result.Data != null)
|
||||||
{
|
{
|
||||||
accountQueryable = accountRepository.GetAccountList().AsQueryable();
|
source = await accountRepository.GetAccountListAsync();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Konto gespeichert", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteAsync(string id)
|
private async Task DeleteAsync(string id)
|
||||||
{
|
{
|
||||||
var account = accountRepository.GetAccount(id);
|
var account = await accountRepository.GetAccountAsync(id);
|
||||||
var dialog = await dialogService.ShowConfirmationAsync($"Konto '{account.Name}' wirklich löschen?", "Ja", "Nein", "Konto löschen");
|
var dialog = await dialogService.ShowConfirmationAsync($"Konto '{account.Name}' wirklich löschen?", "Ja", "Nein", "Konto löschen");
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled)
|
if (!result.Cancelled)
|
||||||
{
|
{
|
||||||
accountRepository.DeleteAccount(id);
|
await accountRepository.DeleteAccountAsync(id);
|
||||||
accountQueryable = accountRepository.GetAccountList().AsQueryable();
|
source = await accountRepository.GetAccountListAsync();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Konto gelöscht", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
@page "/banktransactions"
|
@page "/banktransactions"
|
||||||
<PageTitle>BankTransactions</PageTitle>
|
<PageTitle>BankTransactions</PageTitle>
|
||||||
|
|
||||||
|
<FluentSearch @bind-Value="filter" Immediate="true" Placeholder="Filter…" Style="width: 300px;"/>
|
||||||
<FluentPaginator State="@pagination" />
|
<FluentPaginator State="@pagination" />
|
||||||
|
|
||||||
<div id="datagrid-container">
|
<div id="datagrid-container">
|
||||||
<FluentDataGrid Items="@TransactionList" Pagination="@pagination" RowSize="DataGridRowSize.Small" AutoItemsPerPage="true" Style="overflow-y:hidden;">
|
<FluentDataGrid Items="@Filtered" 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="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="Valutadatum" Width="170px" Property="@(p => p.ValueDate)" Format="dd.MM.yyyy" Sortable="true" />
|
||||||
<PropertyColumn Title="Text" Property="@(p => p.Text)" />
|
<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.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
||||||
|
|
@ -8,11 +8,22 @@ public partial class BankTransactions : ComponentBase
|
||||||
{
|
{
|
||||||
[Inject]
|
[Inject]
|
||||||
private IBankTransactionRepository bankTransactionRepository { get; set; } = null!;
|
private IBankTransactionRepository bankTransactionRepository { get; set; } = null!;
|
||||||
private IQueryable<BankTransaction>? TransactionList;
|
|
||||||
|
private IReadOnlyList<BankTransaction> source = [];
|
||||||
protected override Task OnInitializedAsync()
|
private string filter = "";
|
||||||
{
|
|
||||||
TransactionList = bankTransactionRepository.GetBankTransactionList().AsQueryable();
|
private IQueryable<BankTransaction> Filtered =>
|
||||||
return base.OnInitializedAsync();
|
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()
|
||||||
|
{
|
||||||
|
source = await bankTransactionRepository.GetBankTransactionListAsync();
|
||||||
|
await base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,22 @@
|
||||||
|
|
||||||
<PageTitle>Buchungsregeln</PageTitle>
|
<PageTitle>Buchungsregeln</PageTitle>
|
||||||
|
|
||||||
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neue Regel hinzufügen</FluentButton>
|
<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>
|
||||||
<FluentPaginator State="@pagination" SummaryTemplate="@template" />
|
<FluentPaginator State="@pagination" SummaryTemplate="@template" />
|
||||||
|
|
||||||
<FluentDataGrid Items="@bookingRulesQueryable" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
|
<FluentDataGrid Items="@Filtered" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
|
||||||
<PropertyColumn Property="@(p => p.LookupText)" Sortable="true" IsDefaultSortColumn="true" />
|
<PropertyColumn Property="@(p => p.LookupText)" Sortable="true" IsDefaultSortColumn="true" />
|
||||||
<PropertyColumn Property="@(p => p.LookupValue)" Format="C2" Align="Align.End"/>
|
<PropertyColumn Property="@(p => p.LookupValue)" Format="C2" Align="Align.End"/>
|
||||||
<PropertyColumn Property="@(p => p.BookingText)" />
|
<PropertyColumn Property="@(p => p.BookingText)" />
|
||||||
<PropertyColumn Property="@(p => p.Account)" />
|
<PropertyColumn Property="@(p => p.Account)" />
|
||||||
<TemplateColumn Align="Align.Center" Width="50px">
|
<TemplateColumn Align="Align.Center" Width="50px">
|
||||||
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" OnClick="@(() => DeleteAsync(context.Id))" />
|
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" title="Löschen" aria-label="Löschen" OnClick="@(() => DeleteAsync(context.Id))" />
|
||||||
</TemplateColumn>
|
</TemplateColumn>
|
||||||
<TemplateColumn Align="Align.Center" Width="50px">
|
<TemplateColumn Align="Align.Center" Width="50px">
|
||||||
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" OnClick="@(() => EditAsync(context.Id))" />
|
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" title="Bearbeiten" aria-label="Bearbeiten" OnClick="@(() => EditAsync(context.Id))" />
|
||||||
</TemplateColumn>
|
</TemplateColumn>
|
||||||
</FluentDataGrid>
|
</FluentDataGrid>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.FluentUI.AspNetCore.Components;
|
using Microsoft.FluentUI.AspNetCore.Components;
|
||||||
using Schaad.Accounting.Datasets;
|
using Schaad.Accounting.Datasets;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
|
|
@ -11,21 +11,33 @@ public partial class BookingRules : ComponentBase
|
||||||
{
|
{
|
||||||
[Inject]
|
[Inject]
|
||||||
private IViewService viewService { get; set; } = null!;
|
private IViewService viewService { get; set; } = null!;
|
||||||
|
|
||||||
[Inject]
|
[Inject]
|
||||||
private IBookingRuleRepository bookingRuleRepository { get; set; } = null!;
|
private IBookingRuleRepository bookingRuleRepository { get; set; } = null!;
|
||||||
|
|
||||||
[Inject]
|
[Inject]
|
||||||
private IDialogService dialogService { get; set; } = null!;
|
private IDialogService dialogService { get; set; } = null!;
|
||||||
|
|
||||||
private IQueryable<BookingRuleDataset>? bookingRulesQueryable;
|
[Inject]
|
||||||
|
private IToastService toastService { get; set; } = null!;
|
||||||
protected override Task OnInitializedAsync()
|
|
||||||
{
|
private IReadOnlyList<BookingRuleDataset> source = [];
|
||||||
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable();
|
private string filter = "";
|
||||||
return base.OnInitializedAsync();
|
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
source = await viewService.GetBookingRuleViewListAsync();
|
||||||
|
await base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddAsync()
|
private async Task AddAsync()
|
||||||
{
|
{
|
||||||
var data = new BookingRule();
|
var data = new BookingRule();
|
||||||
|
|
@ -40,13 +52,14 @@ public partial class BookingRules : ComponentBase
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled && result.Data != null)
|
if (!result.Cancelled && result.Data != null)
|
||||||
{
|
{
|
||||||
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable();
|
source = await viewService.GetBookingRuleViewListAsync();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Buchungsregel gespeichert", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EditAsync(string id)
|
private async Task EditAsync(string id)
|
||||||
{
|
{
|
||||||
var data = bookingRuleRepository.GetBookingRule(id);
|
var data = await bookingRuleRepository.GetBookingRuleAsync(id);
|
||||||
var dialog = await dialogService.ShowDialogAsync<BookingRuleDialog>(data, new DialogParameters()
|
var dialog = await dialogService.ShowDialogAsync<BookingRuleDialog>(data, new DialogParameters()
|
||||||
{
|
{
|
||||||
Height = "500px",
|
Height = "500px",
|
||||||
|
|
@ -58,19 +71,21 @@ public partial class BookingRules : ComponentBase
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled && result.Data != null)
|
if (!result.Cancelled && result.Data != null)
|
||||||
{
|
{
|
||||||
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable();
|
source = await viewService.GetBookingRuleViewListAsync();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Buchungsregel gespeichert", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteAsync(string id)
|
private async Task DeleteAsync(string id)
|
||||||
{
|
{
|
||||||
var bookingRule = bookingRuleRepository.GetBookingRule(id);
|
var bookingRule = await bookingRuleRepository.GetBookingRuleAsync(id);
|
||||||
var dialog = await dialogService.ShowConfirmationAsync($"Regel '{bookingRule.LookupText}' wirklich löschen?", "Ja", "Nein", "Buchungsregel löschen");
|
var dialog = await dialogService.ShowConfirmationAsync($"Regel '{bookingRule.LookupText}' wirklich löschen?", "Ja", "Nein", "Buchungsregel löschen");
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled)
|
if (!result.Cancelled)
|
||||||
{
|
{
|
||||||
bookingRuleRepository.DeleteBookingRule(id);
|
await bookingRuleRepository.DeleteBookingRuleAsync(id);
|
||||||
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable();
|
source = await viewService.GetBookingRuleViewListAsync();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Buchungsregel gelöscht", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,19 @@
|
||||||
|
|
||||||
<PageTitle>Buchungstexte</PageTitle>
|
<PageTitle>Buchungstexte</PageTitle>
|
||||||
|
|
||||||
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neuer Text hinzufügen</FluentButton>
|
<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>
|
||||||
<FluentPaginator State="@pagination" SummaryTemplate="@template" />
|
<FluentPaginator State="@pagination" SummaryTemplate="@template" />
|
||||||
|
|
||||||
<FluentDataGrid Items="@bookingTextQueryable" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
|
<FluentDataGrid Items="@Filtered" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
|
||||||
<PropertyColumn Property="@(p => p.Text)" Sortable="true" IsDefaultSortColumn="true" />
|
<PropertyColumn Property="@(p => p.Text)" Sortable="true" IsDefaultSortColumn="true" />
|
||||||
<TemplateColumn Align="Align.Center" Width="50px">
|
<TemplateColumn Align="Align.Center" Width="50px">
|
||||||
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" OnClick="@(() => DeleteAsync(context.Id))" />
|
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" title="Löschen" aria-label="Löschen" OnClick="@(() => DeleteAsync(context.Id))" />
|
||||||
</TemplateColumn>
|
</TemplateColumn>
|
||||||
<TemplateColumn Align="Align.Center" Width="50px">
|
<TemplateColumn Align="Align.Center" Width="50px">
|
||||||
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" OnClick="@(() => EditAsync(context.Id))" />
|
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" title="Bearbeiten" aria-label="Bearbeiten" OnClick="@(() => EditAsync(context.Id))" />
|
||||||
</TemplateColumn>
|
</TemplateColumn>
|
||||||
</FluentDataGrid>
|
</FluentDataGrid>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.FluentUI.AspNetCore.Components;
|
using Microsoft.FluentUI.AspNetCore.Components;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
@ -10,18 +10,27 @@ public partial class BookingTexts : ComponentBase
|
||||||
{
|
{
|
||||||
[Inject]
|
[Inject]
|
||||||
private IBookingTextRepository bookingTextRepository { get; set; } = null!;
|
private IBookingTextRepository bookingTextRepository { get; set; } = null!;
|
||||||
|
|
||||||
[Inject]
|
[Inject]
|
||||||
private IDialogService dialogService { get; set; } = null!;
|
private IDialogService dialogService { get; set; } = null!;
|
||||||
|
|
||||||
private IQueryable<BookingText>? bookingTextQueryable;
|
[Inject]
|
||||||
|
private IToastService toastService { get; set; } = null!;
|
||||||
protected override Task OnInitializedAsync()
|
|
||||||
{
|
private IReadOnlyList<BookingText> source = [];
|
||||||
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable();
|
private string filter = "";
|
||||||
return base.OnInitializedAsync();
|
|
||||||
|
private IQueryable<BookingText> Filtered =>
|
||||||
|
string.IsNullOrWhiteSpace(filter)
|
||||||
|
? source.AsQueryable()
|
||||||
|
: source.AsQueryable().Where(t => t.Text.Contains(filter, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
source = await bookingTextRepository.GetBookingTextListAsync();
|
||||||
|
await base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddAsync()
|
private async Task AddAsync()
|
||||||
{
|
{
|
||||||
var data = new BookingText();
|
var data = new BookingText();
|
||||||
|
|
@ -36,13 +45,14 @@ public partial class BookingTexts : ComponentBase
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled && result.Data != null)
|
if (!result.Cancelled && result.Data != null)
|
||||||
{
|
{
|
||||||
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable();
|
source = await bookingTextRepository.GetBookingTextListAsync();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Buchungstext gespeichert", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EditAsync(string id)
|
private async Task EditAsync(string id)
|
||||||
{
|
{
|
||||||
var data = bookingTextRepository.GetBookingText(id);
|
var data = await bookingTextRepository.GetBookingTextAsync(id);
|
||||||
var dialog = await dialogService.ShowDialogAsync<BookingTextDialog>(data, new DialogParameters()
|
var dialog = await dialogService.ShowDialogAsync<BookingTextDialog>(data, new DialogParameters()
|
||||||
{
|
{
|
||||||
Height = "500px",
|
Height = "500px",
|
||||||
|
|
@ -54,19 +64,21 @@ public partial class BookingTexts : ComponentBase
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled && result.Data != null)
|
if (!result.Cancelled && result.Data != null)
|
||||||
{
|
{
|
||||||
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable();
|
source = await bookingTextRepository.GetBookingTextListAsync();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Buchungstext gespeichert", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteAsync(string id)
|
private async Task DeleteAsync(string id)
|
||||||
{
|
{
|
||||||
var bookingText = bookingTextRepository.GetBookingText(id);
|
var bookingText = await bookingTextRepository.GetBookingTextAsync(id);
|
||||||
var dialog = await dialogService.ShowConfirmationAsync($"Buchungstext '{bookingText.Text}' wirklich löschen?", "Ja", "Nein", "Buchungstext löschen");
|
var dialog = await dialogService.ShowConfirmationAsync($"Buchungstext '{bookingText.Text}' wirklich löschen?", "Ja", "Nein", "Buchungstext löschen");
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled)
|
if (!result.Cancelled)
|
||||||
{
|
{
|
||||||
bookingTextRepository.DeleteBookingText(id);
|
await bookingTextRepository.DeleteBookingTextAsync(id);
|
||||||
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable();
|
source = await bookingTextRepository.GetBookingTextListAsync();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Buchungstext gelöscht", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
@page "/Assets"
|
@page "/Assets"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
<PageTitle>Ausgaben</PageTitle>
|
<PageTitle>Vermögen</PageTitle>
|
||||||
|
|
||||||
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Plotly.Blazor;
|
using Plotly.Blazor;
|
||||||
using Plotly.Blazor.Traces;
|
using Plotly.Blazor.Traces;
|
||||||
|
using Schaad.Accounting.Datasets.Charts;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
|
|
||||||
namespace Schaad.Accounting.UI.Components.Pages.Charts;
|
namespace Schaad.Accounting.UI.Components.Pages.Charts;
|
||||||
|
|
@ -8,48 +9,26 @@ namespace Schaad.Accounting.UI.Components.Pages.Charts;
|
||||||
public partial class Assets : ComponentBase
|
public partial class Assets : ComponentBase
|
||||||
{
|
{
|
||||||
private PlotlyChart chart = null!;
|
private PlotlyChart chart = null!;
|
||||||
private Config config = null!;
|
private Config? config;
|
||||||
private Plotly.Blazor.Layout layout = null!;
|
private Plotly.Blazor.Layout? layout;
|
||||||
private IList<ITrace> data = null!;
|
private IList<ITrace>? data;
|
||||||
|
|
||||||
[Inject]
|
[Inject]
|
||||||
private IViewService viewService { get; set; } = null!;
|
private IViewService viewService { get; set; } = null!;
|
||||||
|
|
||||||
protected override Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
config = new Config
|
config = ChartTheme.BaseConfig();
|
||||||
{
|
layout = ChartTheme.BaseLayout("Vermögen nach Konto");
|
||||||
Responsive = true
|
|
||||||
};
|
|
||||||
|
|
||||||
layout = new Plotly.Blazor.Layout
|
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))
|
||||||
var accounts = viewService.GetAccountViewList().Where(a => a.Class == ClassIds.Activa);
|
.ToList();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data = new List<ITrace>
|
data = new List<ITrace> { ChartTheme.BuildDonutTrace(ChartFormatting.FoldTail(totals)) };
|
||||||
{
|
|
||||||
new Pie
|
|
||||||
{
|
|
||||||
Values = values,
|
|
||||||
Labels = labels,
|
|
||||||
Ids = ids
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return base.OnInitializedAsync();
|
await base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
@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/>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
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)) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
@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/>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
144
Schaad.Accounting.UI/Components/Pages/Charts/ChartTheme.cs
Normal file
144
Schaad.Accounting.UI/Components/Pages/Charts/ChartTheme.cs
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
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,6 +1,15 @@
|
||||||
@page "/spendings"
|
@page "/spendings"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
<PageTitle>Ausgaben</PageTitle>
|
<PageTitle>Ausgaben</PageTitle>
|
||||||
|
|
||||||
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Plotly.Blazor;
|
using Plotly.Blazor;
|
||||||
using Plotly.Blazor.Traces;
|
using Plotly.Blazor.Traces;
|
||||||
|
using Schaad.Accounting.Datasets.Charts;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
|
|
||||||
namespace Schaad.Accounting.UI.Components.Pages.Charts;
|
namespace Schaad.Accounting.UI.Components.Pages.Charts;
|
||||||
|
|
@ -8,45 +9,27 @@ namespace Schaad.Accounting.UI.Components.Pages.Charts;
|
||||||
public partial class Spendings : ComponentBase
|
public partial class Spendings : ComponentBase
|
||||||
{
|
{
|
||||||
private PlotlyChart chart = null!;
|
private PlotlyChart chart = null!;
|
||||||
private Config config = null!;
|
private Config? config;
|
||||||
private Plotly.Blazor.Layout layout = null!;
|
private Plotly.Blazor.Layout? layout;
|
||||||
private IList<ITrace> data = null!;
|
private IList<ITrace>? data;
|
||||||
|
|
||||||
[Inject]
|
[Inject]
|
||||||
private IViewService viewService { get; set; } = null!;
|
private IViewService viewService { get; set; } = null!;
|
||||||
|
|
||||||
protected override Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
config = new Config
|
config = ChartTheme.BaseConfig();
|
||||||
{
|
layout = ChartTheme.BaseLayout("Ausgaben nach Kategorie");
|
||||||
Responsive = true
|
|
||||||
};
|
|
||||||
|
|
||||||
layout = new Plotly.Blazor.Layout
|
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)))
|
||||||
var accounts = viewService.GetAccountViewList().Where(a => a.Class == ClassIds.Expenses);
|
.Where(t => t.Value != 0)
|
||||||
var values = new List<object>();
|
.ToList();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data = new List<ITrace>
|
data = new List<ITrace> { ChartTheme.BuildDonutTrace(ChartFormatting.FoldTail(totals)) };
|
||||||
{
|
|
||||||
new Pie
|
|
||||||
{
|
|
||||||
Values =values,
|
|
||||||
Labels = labels
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return base.OnInitializedAsync();
|
await base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
@page "/SpendingsOverTime"
|
@page "/SpendingsOverTime"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
<PageTitle>Ausgaben im Verlauf</PageTitle>
|
<PageTitle>Ausgaben im Verlauf</PageTitle>
|
||||||
|
|
||||||
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Plotly.Blazor;
|
using Plotly.Blazor;
|
||||||
using Plotly.Blazor.LayoutLib;
|
using Plotly.Blazor.LayoutLib;
|
||||||
using Plotly.Blazor.Traces;
|
using Plotly.Blazor.Traces;
|
||||||
|
|
@ -9,52 +9,44 @@ namespace Schaad.Accounting.UI.Components.Pages.Charts;
|
||||||
public partial class SpendingsOverTime : ComponentBase
|
public partial class SpendingsOverTime : ComponentBase
|
||||||
{
|
{
|
||||||
private PlotlyChart chart = null!;
|
private PlotlyChart chart = null!;
|
||||||
private Config config = null!;
|
private Config? config;
|
||||||
private Plotly.Blazor.Layout layout = null!;
|
private Plotly.Blazor.Layout? layout;
|
||||||
private IList<ITrace> data = new List<ITrace>();
|
private IList<ITrace> data = new List<ITrace>();
|
||||||
|
|
||||||
[Inject]
|
[Inject]
|
||||||
private IChartService chartService { get; set; } = null!;
|
private IChartService chartService { get; set; } = null!;
|
||||||
|
|
||||||
protected override Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
config = new Config
|
|
||||||
{
|
|
||||||
Responsive = true
|
|
||||||
};
|
|
||||||
|
|
||||||
layout = new Plotly.Blazor.Layout
|
protected override async 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>
|
||||||
{
|
{
|
||||||
Title = new Title { Text = "Ausgaben im Verlauf" },
|
new()
|
||||||
BarMode = BarModeEnum.Stack,
|
|
||||||
YAxis = new List<YAxis>
|
|
||||||
{
|
{
|
||||||
new()
|
Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Category,
|
||||||
{
|
GridColor = ChartTheme.GridColor,
|
||||||
Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = "CHF" }
|
LineColor = ChartTheme.GridColor
|
||||||
}
|
|
||||||
},
|
|
||||||
XAxis = new List<XAxis>
|
|
||||||
{
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Date
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
layout.YAxis = new List<YAxis>
|
||||||
|
|
||||||
var dataSeries = chartService.GetExpensesPerMonth();
|
|
||||||
foreach (var dataSerie in dataSeries)
|
|
||||||
{
|
{
|
||||||
data.Add(new Bar
|
new()
|
||||||
{
|
{
|
||||||
Name = dataSerie.Name,
|
Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = "CHF" },
|
||||||
X = dataSerie.X.Select(d => (object)d).ToList(),
|
GridColor = ChartTheme.GridColor,
|
||||||
Y = dataSerie.Y.Select(d => (object)d).ToList(),
|
ZeroLineColor = ChartTheme.GridColor,
|
||||||
});
|
SeparateThousands = true
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return base.OnInitializedAsync();
|
var dataSeries = await chartService.GetExpensesPerMonthAsync();
|
||||||
|
data = ChartTheme.BuildStackedBarTraces(ChartFormatting.FoldTail(dataSeries));
|
||||||
|
|
||||||
|
await base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@
|
||||||
<PropertyColumn Property="@(p => p.Number)" Sortable="true" IsDefaultSortColumn="true" Width="200px" />
|
<PropertyColumn Property="@(p => p.Number)" Sortable="true" IsDefaultSortColumn="true" Width="200px" />
|
||||||
<PropertyColumn Property="@(p => p.Name)" Sortable="true" />
|
<PropertyColumn Property="@(p => p.Name)" Sortable="true" />
|
||||||
<TemplateColumn Align="Align.Center" Width="50px">
|
<TemplateColumn Align="Align.Center" Width="50px">
|
||||||
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" OnClick="@(() => DeleteAsync(context.Id))" />
|
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" title="Löschen" aria-label="Löschen" OnClick="@(() => DeleteAsync(context.Id))" />
|
||||||
</TemplateColumn>
|
</TemplateColumn>
|
||||||
<TemplateColumn Align="Align.Center" Width="50px">
|
<TemplateColumn Align="Align.Center" Width="50px">
|
||||||
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" OnClick="@(() => EditAsync(context.Id))" />
|
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" title="Bearbeiten" aria-label="Bearbeiten" OnClick="@(() => EditAsync(context.Id))" />
|
||||||
</TemplateColumn>
|
</TemplateColumn>
|
||||||
</FluentDataGrid>
|
</FluentDataGrid>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.FluentUI.AspNetCore.Components;
|
using Microsoft.FluentUI.AspNetCore.Components;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
@ -10,18 +10,21 @@ public partial class Classes : ComponentBase
|
||||||
{
|
{
|
||||||
[Inject]
|
[Inject]
|
||||||
private ISubclassRepository subclassRepository { get; set; } = null!;
|
private ISubclassRepository subclassRepository { get; set; } = null!;
|
||||||
|
|
||||||
[Inject]
|
[Inject]
|
||||||
private IDialogService dialogService { get; set; } = null!;
|
private IDialogService dialogService { get; set; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private IToastService toastService { get; set; } = null!;
|
||||||
|
|
||||||
private IQueryable<SubClass>? subclassQueryable;
|
private IQueryable<SubClass>? subclassQueryable;
|
||||||
|
|
||||||
protected override Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable();
|
subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
|
||||||
return base.OnInitializedAsync();
|
await base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddAsync()
|
private async Task AddAsync()
|
||||||
{
|
{
|
||||||
var data = new SubClass();
|
var data = new SubClass();
|
||||||
|
|
@ -36,13 +39,14 @@ public partial class Classes : ComponentBase
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled && result.Data != null)
|
if (!result.Cancelled && result.Data != null)
|
||||||
{
|
{
|
||||||
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable();
|
subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Klasse gespeichert", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EditAsync(string id)
|
private async Task EditAsync(string id)
|
||||||
{
|
{
|
||||||
var data = subclassRepository.GetSubClass(id);
|
var data = await subclassRepository.GetSubClassAsync(id);
|
||||||
var dialog = await dialogService.ShowDialogAsync<ClassDialog>(data, new DialogParameters()
|
var dialog = await dialogService.ShowDialogAsync<ClassDialog>(data, new DialogParameters()
|
||||||
{
|
{
|
||||||
Height = "500px",
|
Height = "500px",
|
||||||
|
|
@ -54,19 +58,21 @@ public partial class Classes : ComponentBase
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled && result.Data != null)
|
if (!result.Cancelled && result.Data != null)
|
||||||
{
|
{
|
||||||
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable();
|
subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Klasse gespeichert", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteAsync(string id)
|
private async Task DeleteAsync(string id)
|
||||||
{
|
{
|
||||||
var subclass = subclassRepository.GetSubClass(id);
|
var subclass = await subclassRepository.GetSubClassAsync(id);
|
||||||
var dialog = await dialogService.ShowConfirmationAsync($"Klasse '{subclass.Name}' wirklich löschen?", "Ja", "Nein", "Klasse löschen");
|
var dialog = await dialogService.ShowConfirmationAsync($"Klasse '{subclass.Name}' wirklich löschen?", "Ja", "Nein", "Klasse löschen");
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
if (!result.Cancelled)
|
if (!result.Cancelled)
|
||||||
{
|
{
|
||||||
subclassRepository.DeleteSubClass(id);
|
await subclassRepository.DeleteSubClassAsync(id);
|
||||||
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable();
|
subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
|
||||||
|
toastService.ShowToast(ToastIntent.Success, "Klasse gelöscht", 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,11 @@ public partial class AccountSelector : ComponentBase
|
||||||
|
|
||||||
private Account? SelectedAccount { get; set; }
|
private Account? SelectedAccount { get; set; }
|
||||||
|
|
||||||
protected override void OnInitialized()
|
// 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()
|
||||||
{
|
{
|
||||||
SelectedAccount = Accounts.FirstOrDefault(account => account.Id == AccountId);
|
SelectedAccount = Accounts.FirstOrDefault(account => account.Id == AccountId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ public partial class AccountDialog : ComponentBase
|
||||||
{
|
{
|
||||||
if (editContext.Validate())
|
if (editContext.Validate())
|
||||||
{
|
{
|
||||||
accountRepository.SaveAccount(Content);
|
await accountRepository.SaveAccountAsync(Content);
|
||||||
await Dialog.CloseAsync(Content);
|
await Dialog.CloseAsync(Content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,12 @@
|
||||||
</FluentDialogHeader>
|
</FluentDialogHeader>
|
||||||
|
|
||||||
<FluentDialogBody>
|
<FluentDialogBody>
|
||||||
|
@if (editContext is null)
|
||||||
|
{
|
||||||
|
<Loading/>
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
<EditForm EditContext="@editContext">
|
<EditForm EditContext="@editContext">
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
<FluentTextField @bind-Value="@Content.LookupText" Label="Suchtext" Style="width: 300px;"/>
|
<FluentTextField @bind-Value="@Content.LookupText" Label="Suchtext" Style="width: 300px;"/>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ namespace Schaad.Accounting.UI.Components.Pages.Dialogs;
|
||||||
|
|
||||||
public partial class BookingRuleDialog : ComponentBase
|
public partial class BookingRuleDialog : ComponentBase
|
||||||
{
|
{
|
||||||
private EditContext editContext = null!;
|
private EditContext? editContext;
|
||||||
|
|
||||||
[CascadingParameter]
|
[CascadingParameter]
|
||||||
public FluentDialog Dialog { get; set; } = null!;
|
public FluentDialog Dialog { get; set; } = null!;
|
||||||
|
|
@ -24,17 +24,17 @@ public partial class BookingRuleDialog : ComponentBase
|
||||||
|
|
||||||
private IReadOnlyList<Account> accounts = [];
|
private IReadOnlyList<Account> accounts = [];
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
editContext = new EditContext(Content);
|
editContext = new EditContext(Content);
|
||||||
accounts = viewService.GetAccountViewList();
|
accounts = await viewService.GetAccountViewListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveAsync()
|
private async Task SaveAsync()
|
||||||
{
|
{
|
||||||
if (editContext.Validate())
|
if (editContext.Validate())
|
||||||
{
|
{
|
||||||
bookingRuleRepository.SaveBookingRule(Content);
|
await bookingRuleRepository.SaveBookingRuleAsync(Content);
|
||||||
await Dialog.CloseAsync(Content);
|
await Dialog.CloseAsync(Content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ public partial class BookingTextDialog : ComponentBase
|
||||||
{
|
{
|
||||||
if (editContext.Validate())
|
if (editContext.Validate())
|
||||||
{
|
{
|
||||||
bookingTextRepository.SaveBookingText(Content);
|
await bookingTextRepository.SaveBookingTextAsync(Content);
|
||||||
await Dialog.CloseAsync(Content);
|
await Dialog.CloseAsync(Content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ public partial class ClassDialog : ComponentBase
|
||||||
{
|
{
|
||||||
if (editContext.Validate())
|
if (editContext.Validate())
|
||||||
{
|
{
|
||||||
bookingRuleRepository.SaveSubClass(Content);
|
await bookingRuleRepository.SaveSubClassAsync(Content);
|
||||||
await Dialog.CloseAsync(Content);
|
await Dialog.CloseAsync(Content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
@using Schaad.Accounting.Models
|
@implements IDialogContentComponent<Schaad.Accounting.Models.Transaction>
|
||||||
@implements IDialogContentComponent<Schaad.Accounting.Models.Transaction>
|
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
<FluentDialogHeader ShowDismiss="true">
|
<FluentDialogHeader ShowDismiss="true">
|
||||||
|
|
@ -12,6 +11,12 @@
|
||||||
</FluentDialogHeader>
|
</FluentDialogHeader>
|
||||||
|
|
||||||
<FluentDialogBody>
|
<FluentDialogBody>
|
||||||
|
@if (editContext is null)
|
||||||
|
{
|
||||||
|
<Loading/>
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
<EditForm EditContext="@editContext">
|
<EditForm EditContext="@editContext">
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
<FluentDatePicker @bind-Value="@SelectedValue" Label="Valuta" Style="width: 300px;"/>
|
<FluentDatePicker @bind-Value="@SelectedValue" Label="Valuta" Style="width: 300px;"/>
|
||||||
|
|
@ -37,6 +42,7 @@
|
||||||
@bind-Value="@Content.TargetAccountId" />
|
@bind-Value="@Content.TargetAccountId" />
|
||||||
<FluentNumberField @bind-Value="@Content.Value" Label="Betrag" Style="width: 300px;"/>
|
<FluentNumberField @bind-Value="@Content.Value" Label="Betrag" Style="width: 300px;"/>
|
||||||
<FluentTextField @bind-Value="@Content.Text" Label="Buchungstext" Style="width: 300px;" />
|
<FluentTextField @bind-Value="@Content.Text" Label="Buchungstext" Style="width: 300px;" />
|
||||||
|
<FluentNumberField @bind-Value="@Content.FxRate" Label="Kurs" Style="width: 100px;" />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FluentValidationSummary />
|
<FluentValidationSummary />
|
||||||
|
|
@ -50,6 +56,7 @@
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</FluentButton>
|
</FluentButton>
|
||||||
<FluentButton Appearance="Appearance.Accent"
|
<FluentButton Appearance="Appearance.Accent"
|
||||||
|
Disabled="@(SelectedValue is null)"
|
||||||
OnClick="@SaveAsync">
|
OnClick="@SaveAsync">
|
||||||
Speichern
|
Speichern
|
||||||
</FluentButton>
|
</FluentButton>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ namespace Schaad.Accounting.UI.Components.Pages.Dialogs;
|
||||||
|
|
||||||
public partial class TransactionDialog : ComponentBase
|
public partial class TransactionDialog : ComponentBase
|
||||||
{
|
{
|
||||||
private EditContext editContext = null!;
|
private EditContext? editContext;
|
||||||
|
|
||||||
[CascadingParameter]
|
[CascadingParameter]
|
||||||
public FluentDialog Dialog { get; set; } = null!;
|
public FluentDialog Dialog { get; set; } = null!;
|
||||||
|
|
@ -25,20 +25,24 @@ public partial class TransactionDialog : ComponentBase
|
||||||
private IReadOnlyList<Account> accounts = [];
|
private IReadOnlyList<Account> accounts = [];
|
||||||
private DateTime? SelectedValue;
|
private DateTime? SelectedValue;
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
SelectedValue = Content.ValueDate;
|
SelectedValue = Content.ValueDate;
|
||||||
editContext = new EditContext(Content);
|
editContext = new EditContext(Content);
|
||||||
accounts = viewService.GetAccountViewList();
|
accounts = await viewService.GetAccountViewListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveAsync()
|
private async Task SaveAsync()
|
||||||
{
|
{
|
||||||
|
if (editContext is null || SelectedValue is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (editContext.Validate())
|
if (editContext.Validate())
|
||||||
{
|
{
|
||||||
Content.ValueDate = SelectedValue!.Value;
|
Content.ValueDate = SelectedValue.Value;
|
||||||
Content.BookingDate = SelectedValue!.Value;
|
Content.BookingDate = SelectedValue.Value;
|
||||||
transactionRepository.SaveTransaction(Content);
|
await transactionRepository.SaveTransactionAsync(Content);
|
||||||
await Dialog.CloseAsync(Content);
|
await Dialog.CloseAsync(Content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,12 @@
|
||||||
</FluentDialogHeader>
|
</FluentDialogHeader>
|
||||||
|
|
||||||
<FluentDialogBody>
|
<FluentDialogBody>
|
||||||
|
@if (editContext is null)
|
||||||
|
{
|
||||||
|
<Loading/>
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Total: @Math.Abs(Content.Value) / Rest: @openAmount
|
Total: @Math.Abs(Content.Value) / Rest: @openAmount
|
||||||
<FluentButton Appearance="Appearance.Neutral" IconEnd="@(new Icons.Regular.Size24.Add())"
|
<FluentButton Appearance="Appearance.Neutral" IconEnd="@(new Icons.Regular.Size24.Add())"
|
||||||
OnClick="@AddTransactionAsync">
|
OnClick="@AddTransactionAsync">
|
||||||
|
|
@ -59,6 +65,7 @@
|
||||||
</td>
|
</td>
|
||||||
<td style="align-content: center">
|
<td style="align-content: center">
|
||||||
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral"
|
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral"
|
||||||
|
title="Zeile entfernen" aria-label="Zeile entfernen"
|
||||||
OnClick="@(() => transactionList.Remove(transaction))"/>
|
OnClick="@(() => transactionList.Remove(transaction))"/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -67,6 +74,15 @@
|
||||||
</table>
|
</table>
|
||||||
<div>
|
<div>
|
||||||
<FluentValidationSummary/>
|
<FluentValidationSummary/>
|
||||||
|
@if (validationErrors.Count > 0)
|
||||||
|
{
|
||||||
|
<ul class="validation-message">
|
||||||
|
@foreach (var err in validationErrors)
|
||||||
|
{
|
||||||
|
<li>@err</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</EditForm>
|
</EditForm>
|
||||||
</FluentDialogBody>
|
</FluentDialogBody>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ namespace Schaad.Accounting.UI.Components.Pages.Dialogs;
|
||||||
|
|
||||||
public partial class TransactionSplitDialog : ComponentBase
|
public partial class TransactionSplitDialog : ComponentBase
|
||||||
{
|
{
|
||||||
private EditContext editContext = null!;
|
private EditContext? editContext;
|
||||||
|
|
||||||
[CascadingParameter]
|
[CascadingParameter]
|
||||||
public FluentDialog Dialog { get; set; } = null!;
|
public FluentDialog Dialog { get; set; } = null!;
|
||||||
|
|
@ -33,17 +33,18 @@ public partial class TransactionSplitDialog : ComponentBase
|
||||||
private IViewService viewService { get; set; } = null!;
|
private IViewService viewService { get; set; } = null!;
|
||||||
|
|
||||||
private List<Transaction> transactionList = new();
|
private List<Transaction> transactionList = new();
|
||||||
private IReadOnlyList<AccountDataset> accounts = null!;
|
private IReadOnlyList<AccountDataset> accounts = [];
|
||||||
private IReadOnlyList<string> bookingTexts = [];
|
private IReadOnlyList<string> bookingTexts = [];
|
||||||
private decimal openAmount = 0;
|
private decimal openAmount = 0;
|
||||||
|
private List<string> validationErrors = new();
|
||||||
|
|
||||||
protected override void OnInitialized()
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
accounts = viewService.GetAccountViewList();
|
accounts = await viewService.GetAccountViewListAsync();
|
||||||
bookingTexts = bookingTextRepository.GetBookingTextList().Select(b => b.Text).ToArray();
|
bookingTexts = (await bookingTextRepository.GetBookingTextListAsync()).Select(b => b.Text).ToArray();
|
||||||
var accountList = accountRepository.GetAccountList();
|
var accountList = await accountRepository.GetAccountListAsync();
|
||||||
var bankTrx = bankTransactionRepository.GetBankTransaction(Content.BankTransactionId);
|
var bankTrx = await bankTransactionRepository.GetBankTransactionAsync(Content.BankTransactionId);
|
||||||
var trx = new Transaction(bankTrx, accountList);
|
var trx = new Transaction(bankTrx, accountList);
|
||||||
trx.Value = Math.Abs(bankTrx.Value);
|
trx.Value = Math.Abs(bankTrx.Value);
|
||||||
transactionList.Add(trx);
|
transactionList.Add(trx);
|
||||||
|
|
@ -68,16 +69,43 @@ public partial class TransactionSplitDialog : ComponentBase
|
||||||
openAmount = Content.Value + transactionList.Sum(t => t.Value);
|
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()
|
private async Task SaveAsync()
|
||||||
{
|
{
|
||||||
if (editContext.Validate())
|
if (!ValidateAllRows())
|
||||||
{
|
{
|
||||||
foreach (var transtaction in transactionList)
|
return;
|
||||||
{
|
|
||||||
transactionRepository.SaveTransaction(transtaction);
|
|
||||||
}
|
|
||||||
await Dialog.CloseAsync(Content);
|
|
||||||
}
|
}
|
||||||
|
foreach (var transtaction in transactionList)
|
||||||
|
{
|
||||||
|
await transactionRepository.SaveTransactionAsync(transtaction);
|
||||||
|
}
|
||||||
|
await Dialog.CloseAsync(Content);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CancelAsync()
|
private async Task CancelAsync()
|
||||||
|
|
|
||||||
|
|
@ -1,101 +1,9 @@
|
||||||
@page "/"
|
@page "/"
|
||||||
|
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
<PageTitle>Home</PageTitle>
|
<PageTitle>Home</PageTitle>
|
||||||
|
|
||||||
<FluentInputFile @ref="@myFileByBuffer"
|
<StatementImport OnImported="RefreshPendingAsync"/>
|
||||||
AnchorId="MyUploadBuffer"
|
|
||||||
DragDropZoneVisible="false"
|
|
||||||
Mode="InputFileMode.Buffer"
|
|
||||||
Multiple="true"
|
|
||||||
MaximumFileSize="@(10 * 1024 * 1024)"
|
|
||||||
Accept=".zip, .xml"
|
|
||||||
OnProgressChange="@OnProgressChangeAsync"
|
|
||||||
OnCompleted="@OnCompletedAsync"/>
|
|
||||||
|
|
||||||
<FluentProgress Visible="@(progressPercent > 0)" Min="0" Max="100" Value="@progressPercent"/>
|
<PendingBookings @ref="pendingBookings"/>
|
||||||
<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>Betrag</th>
|
|
||||||
<th>Zusatzinfo</th>
|
|
||||||
<th>Text</th>
|
|
||||||
<th>Konto</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="betrag-column">
|
|
||||||
<div>@Math.Abs(transaction.Value).ToFormattedString()</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..."/>
|
|
||||||
<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 style="align-content: center">
|
|
||||||
<FluentButton IconEnd="@(new Icons.Regular.Size16.SplitVertical())" Appearance="Appearance.Neutral"
|
|
||||||
OnClick="@(() => SplitBankTransactionAsync(transaction))" Disabled="@(transaction.Value > 0)"/>
|
|
||||||
</td>
|
|
||||||
<td style="align-content: center">
|
|
||||||
<FluentButton IconEnd="@(new Icons.Regular.Size16.Save())" Appearance="Appearance.Neutral"
|
|
||||||
OnClick="@(() => BookBankTransactionAsync(transaction))"/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,197 +1,16 @@
|
||||||
using System.IO.Compression;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
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;
|
namespace Schaad.Accounting.UI.Components.Pages;
|
||||||
|
|
||||||
public partial class Home : ComponentBase
|
public partial class Home : ComponentBase
|
||||||
{
|
{
|
||||||
[Inject]
|
private PendingBookings? pendingBookings;
|
||||||
private IViewService viewService { get; set; } = null!;
|
|
||||||
|
|
||||||
[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 async Task RefreshPendingAsync()
|
||||||
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 (loaded == false) {
|
if (pendingBookings is not null)
|
||||||
matchingBankTransactions = viewService.MatchOpenBankTransactions();
|
|
||||||
accounts = viewService.GetAccountViewList();
|
|
||||||
bookingTexts = bookingTextRepository.GetBookingTextList().Select(b => b.Text).ToArray();
|
|
||||||
loaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return base.OnInitializedAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task OnProgressChangeAsync(FluentInputFileEventArgs file)
|
|
||||||
{
|
|
||||||
progressPercent = file.ProgressPercent;
|
|
||||||
progressTitle = file.ProgressTitle;
|
|
||||||
|
|
||||||
// To cancel?
|
|
||||||
file.IsCancelled = IsCanceled;
|
|
||||||
|
|
||||||
// New file
|
|
||||||
if (!Files.ContainsKey(file.Index))
|
|
||||||
{
|
{
|
||||||
var localFile = Path.GetTempFileName() + file.Name;
|
await pendingBookings.RefreshAsync();
|
||||||
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);
|
|
||||||
ReloadPage();
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,14 @@ th {
|
||||||
background-color: #f8f8f8;
|
background-color: #f8f8f8;
|
||||||
}
|
}
|
||||||
|
|
||||||
th:nth-child(1), td:nth-child(1) { width: 120px; }
|
th:nth-child(1), td:nth-child(1) { width: 120px; } /* Booking Date */
|
||||||
th:nth-child(2), td:nth-child(2) { width: 120px; }
|
th:nth-child(2), td:nth-child(2) { width: 120px; } /* Valuta Date */
|
||||||
th:nth-child(3), td:nth-child(3) { width: 100px; text-align: right; }
|
th:nth-child(3), td:nth-child(3) { } /* Zusatzinfo */
|
||||||
th:nth-child(5), td:nth-child(5) { width: 240px; }
|
th:nth-child(4), td:nth-child(4) { width: 240px; } /* Text */
|
||||||
th:nth-child(6), td:nth-child(6) { width: 300px; }
|
th:nth-child(5), td:nth-child(5) { width: 300px; } /* Account */
|
||||||
th:nth-child(7), td:nth-child(7),
|
th:nth-child(6), td:nth-child(6) { width: 100px; text-align: right; } /* Amount */
|
||||||
th:nth-child(8), td:nth-child(8) { width: 40px; text-align: center; }
|
th:nth-child(7), td:nth-child(7) { width: 40px; text-align: center; } /* Split */
|
||||||
|
th:nth-child(8), td:nth-child(8) { width: 40px; text-align: center; } /* Save */
|
||||||
|
|
||||||
.text-column input {
|
.text-column input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -35,9 +36,4 @@ th:nth-child(8), td:nth-child(8) { width: 40px; text-align: center; }
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.betrag-column {
|
|
||||||
text-align: right;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
75
Schaad.Accounting.UI/Components/Pages/PendingBookings.razor
Normal file
75
Schaad.Accounting.UI/Components/Pages/PendingBookings.razor
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
@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>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue