Compare commits
7 commits
a16aedcfd8
...
8926fb1e97
| Author | SHA1 | Date | |
|---|---|---|---|
| 8926fb1e97 | |||
| 5d75bbb64d | |||
| 2eedd2fd20 | |||
| 1c7ba7f416 | |||
| 8961bb234c | |||
| 152d4c3d57 | |||
| 9f11f6490b |
34 changed files with 1371 additions and 318 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -41,6 +41,9 @@ ScaffoldingReadMe.txt
|
|||
*~
|
||||
CodeCoverage/
|
||||
|
||||
# JetBrains Rider / ReSharper user-specific settings
|
||||
*.DotSettings.user
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
|
|
|
|||
|
|
@ -11,28 +11,78 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Services"
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Db", "Schaad.Accounting.Db\Schaad.Accounting.Db.csproj", "{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Tests", "Schaad.Accounting.Tests\Schaad.Accounting.Tests.csproj", "{C0A48984-3770-49D7-9371-25351B4E79FF}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|x64.Build.0 = Release|Any CPU
|
||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|x86.Build.0 = Release|Any CPU
|
||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|x64.Build.0 = Release|Any CPU
|
||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|x86.Build.0 = Release|Any CPU
|
||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|x64.Build.0 = Release|Any CPU
|
||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|x86.Build.0 = Release|Any CPU
|
||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|x64.Build.0 = Release|Any CPU
|
||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
|
|
|||
32
CLAUDE.md
32
CLAUDE.md
|
|
@ -11,11 +11,14 @@ dotnet build
|
|||
# Run (dev server at http://localhost:5225)
|
||||
cd Schaad.Accounting.UI && dotnet run
|
||||
|
||||
# Test
|
||||
dotnet test
|
||||
|
||||
# Publish
|
||||
dotnet publish -c Release
|
||||
```
|
||||
|
||||
There are no automated tests or lint commands.
|
||||
Tests live in `Schaad.Accounting.Tests` (xUnit + NSubstitute + Shouldly). There is no lint command.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -61,4 +64,29 @@ Pages live in `Schaad.Accounting.UI/Components/Pages/`. Each page typically has
|
|||
|
||||
### Domain constants
|
||||
|
||||
`ClassIds` in `Schaad.Accounting.Common` defines the Swiss accounting chart-of-accounts classes: Activa=1000, Passiva=2000, Income=3000, Expenses=4000.
|
||||
`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.
|
||||
|
|
|
|||
105
IMPROVEMENT_PLAN.md
Normal file
105
IMPROVEMENT_PLAN.md
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
# 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)** — deferred. After PR D, each XML file is loaded at most once per SignalR circuit, and this app is single-user local Blazor Server. Converting every repository/service method to async would touch ~40 files for negligible user-visible benefit and real regression risk. Revisit if the app is ever hosted for multiple concurrent users.
|
||||
- **PR G** — Phase 4 items 23 + 24: `Schaad.Accounting.Tests` xUnit + NSubstitute + Shouldly project. Adopts the `xxxTestShould.DoThisWhenThat` naming convention with Shouldly assertions (no xUnit `Assert.*`). Covers `ViewService`, `TransactionRepository`, `Formatting`, `RepositoryCache`, `AccountRepository`, `ChartService`, and `FileService.GetTransactionListCsv` — 38 tests locking in the earlier PRs' behavior. Note: originally shipped as three separate commits (initial project, expanded coverage, Shouldly + naming conversion) and later squashed into one commit at the user's request.
|
||||
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,9 +1,9 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using Schaad.Accounting.Models;
|
||||
|
||||
namespace Schaad.Accounting.Interfaces
|
||||
{
|
||||
public interface ISplitPredefinitonRepository
|
||||
public interface ISplitPredefinitionRepository
|
||||
{
|
||||
List<SplitPredefinition> GetSplitPredefinitionList();
|
||||
|
||||
|
|
@ -4,17 +4,9 @@
|
|||
<TargetFramework>net9.0</TargetFramework>
|
||||
<RootNamespace>Schaad.Accounting</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
||||
<PackageReference Include="System.Xml.XmlSerializer" Version="4.3.0" />
|
||||
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Schaad.Finance.Api">
|
||||
<HintPath>..\dlls\Schaad.Finance.Api.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Interfaces\Extensions\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -11,20 +11,16 @@ namespace Schaad.Accounting.Repositories
|
|||
{
|
||||
private readonly string ACCOUNTS = "Accounts.xml";
|
||||
|
||||
public AccountRepository(ISettingsService settingsService) : base(settingsService)
|
||||
public AccountRepository(ISettingsService settingsService, RepositoryCache cache)
|
||||
: base(settingsService, cache)
|
||||
{
|
||||
EnsureFileExisits(ACCOUNTS);
|
||||
EnsureAccountsFile();
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Load accounts
|
||||
/// </summary>
|
||||
public List<Account> GetAccountList()
|
||||
{
|
||||
var accounts = Load<List<Account>>(ACCOUNTS);
|
||||
return accounts ?? new List<Account>();
|
||||
}
|
||||
public List<Account> GetAccountList() => LoadList<Account>(ACCOUNTS);
|
||||
|
||||
/// <summary>
|
||||
/// Save an account (insert/update)
|
||||
|
|
@ -79,35 +75,40 @@ namespace Schaad.Accounting.Repositories
|
|||
SaveAccount(account);
|
||||
}
|
||||
|
||||
private new void EnsureFileExisits(string file)
|
||||
private void EnsureAccountsFile()
|
||||
{
|
||||
string filePath = Path.Combine(settingsService.GetDbPath(), file);
|
||||
if (File.Exists(filePath) == false)
|
||||
string filePath = Path.Combine(settingsService.GetDbPath(), ACCOUNTS);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), file);
|
||||
if (File.Exists(lastYearFile))
|
||||
File.Copy(lastYearFile, filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// set start balance to last bank balance
|
||||
if (file.IndexOf("Accounts") > -1)
|
||||
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), ACCOUNTS);
|
||||
if (File.Exists(lastYearFile))
|
||||
{
|
||||
File.Copy(lastYearFile, filePath);
|
||||
}
|
||||
|
||||
// Year rollover: seed start balances from the previous year's closing values.
|
||||
// Bank accounts start at the last known bank balance; cash accounts start at zero.
|
||||
var accounts = GetAccountList();
|
||||
var modified = false;
|
||||
foreach (var account in accounts.Where(a => a.Class == ClassIds.Activa))
|
||||
{
|
||||
// bank accounts
|
||||
if (account.LastBankBalance > 0)
|
||||
{
|
||||
account.StartBalance = account.LastBankBalance;
|
||||
SaveAccount(account);
|
||||
modified = true;
|
||||
}
|
||||
// cash accounts
|
||||
else if (account.Number < 1010)
|
||||
{
|
||||
account.StartBalance = 0;
|
||||
SaveAccount(account);
|
||||
}
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
if (modified)
|
||||
{
|
||||
Save(accounts, ACCOUNTS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,18 +10,15 @@ namespace Schaad.Accounting.Repositories
|
|||
{
|
||||
private readonly string BANK_TRANSACTIONS = "BankTransactions.xml";
|
||||
|
||||
public BankTransactionRepository(ISettingsService settingsService) : base(settingsService)
|
||||
public BankTransactionRepository(ISettingsService settingsService, RepositoryCache cache)
|
||||
: base(settingsService, cache)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load bank transactions
|
||||
/// </summary>
|
||||
public List<BankTransaction> GetBankTransactionList()
|
||||
{
|
||||
var transactions = Load<List<BankTransaction>>(BANK_TRANSACTIONS);
|
||||
return transactions != null ? transactions : new List<BankTransaction>();
|
||||
}
|
||||
public List<BankTransaction> GetBankTransactionList() => LoadList<BankTransaction>(BANK_TRANSACTIONS);
|
||||
|
||||
/// <summary>
|
||||
/// Save new bank transactions, ignore existing ones (no update)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
using System.IO;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using System.Xml.Serialization;
|
||||
|
|
@ -8,16 +11,25 @@ namespace Schaad.Accounting.Repositories
|
|||
{
|
||||
public abstract class BaseRepository
|
||||
{
|
||||
protected readonly ISettingsService settingsService;
|
||||
// One lock per absolute file path so concurrent Save+Save and Save+Load are serialized
|
||||
// and can't observe a half-written file.
|
||||
private static readonly ConcurrentDictionary<string, object> FileLocks =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
protected BaseRepository(ISettingsService settingsService)
|
||||
protected readonly ISettingsService settingsService;
|
||||
protected readonly RepositoryCache cache;
|
||||
|
||||
protected BaseRepository(ISettingsService settingsService, RepositoryCache cache)
|
||||
{
|
||||
this.settingsService = settingsService;
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
protected void EnsureFileExisits(string fileName)
|
||||
protected void EnsureFileExists(string fileName)
|
||||
{
|
||||
string filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
||||
lock (GetLock(filePath))
|
||||
{
|
||||
if (File.Exists(filePath) == false)
|
||||
{
|
||||
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), fileName);
|
||||
|
|
@ -27,36 +39,69 @@ namespace Schaad.Accounting.Repositories
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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="Save"/> invalidates the entry.
|
||||
/// </summary>
|
||||
protected List<T> LoadList<T>(string fileName)
|
||||
{
|
||||
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
||||
return cache.GetOrLoad(filePath, () => Load<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 void Save<T>(T obj, string fileName)
|
||||
{
|
||||
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
||||
using (var sww = new MemoryStream())
|
||||
var tmpPath = filePath + ".tmp";
|
||||
|
||||
lock (GetLock(filePath))
|
||||
{
|
||||
var settings = new XmlWriterSettings
|
||||
{
|
||||
Encoding = Encoding.UTF8,
|
||||
Indent = true
|
||||
};
|
||||
using (var writer = XmlWriter.Create(sww, settings))
|
||||
|
||||
try
|
||||
{
|
||||
var xsSubmit = new XmlSerializer(typeof(T));
|
||||
xsSubmit.Serialize(writer, obj);
|
||||
var xml = Encoding.UTF8.GetString(sww.ToArray());
|
||||
File.WriteAllText(filePath, xml);
|
||||
using (var writer = XmlWriter.Create(tmpPath, settings))
|
||||
{
|
||||
var serializer = new XmlSerializer(typeof(T));
|
||||
serializer.Serialize(writer, obj);
|
||||
}
|
||||
|
||||
File.Move(tmpPath, filePath, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (File.Exists(tmpPath))
|
||||
{
|
||||
try { File.Delete(tmpPath); } catch { /* best effort */ }
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
cache.Invalidate(filePath);
|
||||
}
|
||||
|
||||
/// <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="LoadList{T}"/> for repository reads.
|
||||
/// </summary>
|
||||
protected T Load<T>(string fileName)
|
||||
{
|
||||
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
||||
|
||||
lock (GetLock(filePath))
|
||||
{
|
||||
if (File.Exists(filePath) == false)
|
||||
{
|
||||
return default(T);
|
||||
|
|
@ -69,4 +114,8 @@ namespace Schaad.Accounting.Repositories
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static object GetLock(string filePath)
|
||||
=> FileLocks.GetOrAdd(filePath, _ => new object());
|
||||
}
|
||||
}
|
||||
|
|
@ -10,19 +10,16 @@ namespace Schaad.Accounting.Repositories
|
|||
{
|
||||
private readonly string BOOKING_RULES = "BookingRules.xml";
|
||||
|
||||
public BookingRuleRepository(ISettingsService settingsService) : base(settingsService)
|
||||
public BookingRuleRepository(ISettingsService settingsService, RepositoryCache cache)
|
||||
: base(settingsService, cache)
|
||||
{
|
||||
EnsureFileExisits(BOOKING_RULES);
|
||||
EnsureFileExists(BOOKING_RULES);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load booking rules
|
||||
/// </summary>
|
||||
public List<BookingRule> GetBookingRuleList()
|
||||
{
|
||||
var bookingTexts = Load<List<BookingRule>>(BOOKING_RULES);
|
||||
return bookingTexts ?? new List<BookingRule>();
|
||||
}
|
||||
public List<BookingRule> GetBookingRuleList() => LoadList<BookingRule>(BOOKING_RULES);
|
||||
|
||||
/// <summary>
|
||||
/// Save a booking rule (insert/update)
|
||||
|
|
|
|||
|
|
@ -10,19 +10,16 @@ namespace Schaad.Accounting.Repositories
|
|||
{
|
||||
private readonly string BOOKING_TEXTS = "BookingTexts.xml";
|
||||
|
||||
public BookingTextRepository(ISettingsService settingsService) : base(settingsService)
|
||||
public BookingTextRepository(ISettingsService settingsService, RepositoryCache cache)
|
||||
: base(settingsService, cache)
|
||||
{
|
||||
EnsureFileExisits(BOOKING_TEXTS);
|
||||
EnsureFileExists(BOOKING_TEXTS);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load bokking texts
|
||||
/// Load booking texts
|
||||
/// </summary>
|
||||
public List<BookingText> GetBookingTextList()
|
||||
{
|
||||
var bookingTexts = Load<List<BookingText>>(BOOKING_TEXTS);
|
||||
return bookingTexts ?? new List<BookingText>();
|
||||
}
|
||||
public List<BookingText> GetBookingTextList() => LoadList<BookingText>(BOOKING_TEXTS);
|
||||
|
||||
/// <summary>
|
||||
/// Save a booking text (insert/update)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Schaad.Accounting.Interfaces;
|
||||
|
|
@ -6,26 +6,23 @@ using Schaad.Accounting.Models;
|
|||
|
||||
namespace Schaad.Accounting.Repositories
|
||||
{
|
||||
public class SplitPredefinitonRepository : BaseRepository, ISplitPredefinitonRepository
|
||||
public class SplitPredefinitionRepository : BaseRepository, ISplitPredefinitionRepository
|
||||
{
|
||||
private readonly string SPLIT_PREDEFINITION = "SplitPredefinitions.xml";
|
||||
|
||||
public SplitPredefinitonRepository(ISettingsService settingsService) : base(settingsService)
|
||||
public SplitPredefinitionRepository(ISettingsService settingsService, RepositoryCache cache)
|
||||
: base(settingsService, cache)
|
||||
{
|
||||
EnsureFileExisits(SPLIT_PREDEFINITION);
|
||||
EnsureFileExists(SPLIT_PREDEFINITION);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load booking rules
|
||||
/// Load split predefinitions
|
||||
/// </summary>
|
||||
public List<SplitPredefinition> GetSplitPredefinitionList()
|
||||
{
|
||||
var definitions = Load<List<SplitPredefinition>>(SPLIT_PREDEFINITION);
|
||||
return definitions ?? new List<SplitPredefinition>();
|
||||
}
|
||||
public List<SplitPredefinition> GetSplitPredefinitionList() => LoadList<SplitPredefinition>(SPLIT_PREDEFINITION);
|
||||
|
||||
/// <summary>
|
||||
/// Save a booking rule (insert/update)
|
||||
/// Save a split predefinition (insert/update)
|
||||
/// </summary>
|
||||
public void SaveSplitPredefinition(SplitPredefinition splitPredefinition)
|
||||
{
|
||||
|
|
@ -11,9 +11,10 @@ namespace Schaad.Accounting.Repositories
|
|||
private readonly Dictionary<int, string> classes = new Dictionary<int, string>();
|
||||
private readonly string SUBCLASSES = "SubClasses.xml";
|
||||
|
||||
public SubclassRepository(ISettingsService settingsService) : base(settingsService)
|
||||
public SubclassRepository(ISettingsService settingsService, RepositoryCache cache)
|
||||
: base(settingsService, cache)
|
||||
{
|
||||
EnsureFileExisits(SUBCLASSES);
|
||||
EnsureFileExists(SUBCLASSES);
|
||||
|
||||
classes.Add(ClassIds.Activa, "Aktiven");
|
||||
classes.Add(ClassIds.Passiva, "Passiven");
|
||||
|
|
@ -22,13 +23,9 @@ namespace Schaad.Accounting.Repositories
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load bokking texts
|
||||
/// Load subclasses
|
||||
/// </summary>
|
||||
public List<SubClass> GetSubClassList()
|
||||
{
|
||||
var subclasses = Load<List<SubClass>>(SUBCLASSES);
|
||||
return subclasses ?? new List<SubClass>();
|
||||
}
|
||||
public List<SubClass> GetSubClassList() => LoadList<SubClass>(SUBCLASSES);
|
||||
|
||||
/// <summary>
|
||||
/// Save a booking text (insert/update)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ namespace Schaad.Accounting.Repositories
|
|||
private readonly IAccountRepository accountRepository;
|
||||
private readonly string TRANSACTIONS = "Transactions.xml";
|
||||
|
||||
public TransactionRepository(ISettingsService settingsService, IAccountRepository accountRepository) : base(settingsService)
|
||||
public TransactionRepository(ISettingsService settingsService, RepositoryCache cache, IAccountRepository accountRepository)
|
||||
: base(settingsService, cache)
|
||||
{
|
||||
this.accountRepository = accountRepository;
|
||||
}
|
||||
|
|
@ -19,11 +20,7 @@ namespace Schaad.Accounting.Repositories
|
|||
/// <summary>
|
||||
/// Get transaction list
|
||||
/// </summary>
|
||||
public List<Transaction> GetTransactionList()
|
||||
{
|
||||
var transactionList = Load<List<Transaction>>(TRANSACTIONS);
|
||||
return transactionList ?? new List<Transaction>();
|
||||
}
|
||||
public List<Transaction> GetTransactionList() => LoadList<Transaction>(TRANSACTIONS);
|
||||
|
||||
/// <summary>
|
||||
/// Save a transaction (insert/update)
|
||||
|
|
@ -67,17 +64,24 @@ namespace Schaad.Accounting.Repositories
|
|||
public Transaction GetTransaction(string id)
|
||||
{
|
||||
var transactions = GetTransactionList();
|
||||
var transaction = transactions.FirstOrDefault(t => t.Id == id);
|
||||
|
||||
// value is stored in CHF -> convert back to foreign currency for display/editing
|
||||
var isFxAccount = accountRepository.GetAccount(transaction.OriginAccountId).IsFxAccount
|
||||
|| accountRepository.GetAccount(transaction.TargetAccountId).IsFxAccount;
|
||||
if (isFxAccount && transaction.FxRate != 0)
|
||||
var stored = transactions.FirstOrDefault(t => t.Id == id);
|
||||
if (stored == null)
|
||||
{
|
||||
transaction.Value = transaction.Value / transaction.FxRate.Value;
|
||||
return null;
|
||||
}
|
||||
|
||||
return transaction;
|
||||
var result = new Transaction();
|
||||
stored.Copy(result);
|
||||
|
||||
// value is stored in CHF -> convert back to foreign currency for display/editing
|
||||
var isFxAccount = accountRepository.GetAccount(result.OriginAccountId).IsFxAccount
|
||||
|| accountRepository.GetAccount(result.TargetAccountId).IsFxAccount;
|
||||
if (isFxAccount && result.FxRate != 0)
|
||||
{
|
||||
result.Value = result.Value / result.FxRate.Value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
38
Schaad.Accounting.Db/RepositoryCache.cs
Normal file
38
Schaad.Accounting.Db/RepositoryCache.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
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.Save"/>,
|
||||
/// 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 List<T> GetOrLoad<T>(string cacheKey, Func<List<T>> loader)
|
||||
{
|
||||
if (entries.TryGetValue(cacheKey, out var cached))
|
||||
{
|
||||
return (List<T>)cached;
|
||||
}
|
||||
|
||||
var list = loader();
|
||||
entries[cacheKey] = list;
|
||||
return list;
|
||||
}
|
||||
|
||||
public void Invalidate(string cacheKey)
|
||||
{
|
||||
entries.Remove(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,9 +4,6 @@
|
|||
<TargetFramework>net9.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Schaad.Accounting.Datasets;
|
||||
using Schaad.Accounting.Datasets.Charts;
|
||||
using Schaad.Accounting.Interfaces;
|
||||
|
||||
|
|
@ -8,115 +9,88 @@ namespace Schaad.Accounting.Services
|
|||
{
|
||||
public class ChartService : IChartService
|
||||
{
|
||||
private readonly IAccountRepository accountRepository;
|
||||
private readonly ISettingsService settingsService;
|
||||
private readonly ISubclassRepository subclassRepository;
|
||||
private readonly IViewService viewService;
|
||||
|
||||
public ChartService(
|
||||
ISettingsService settingsService,
|
||||
IViewService viewService,
|
||||
IAccountRepository accountRepository,
|
||||
ISubclassRepository subclassRepository)
|
||||
public ChartService(ISettingsService settingsService, IViewService viewService)
|
||||
{
|
||||
this.settingsService = settingsService;
|
||||
this.viewService = viewService;
|
||||
this.accountRepository = accountRepository;
|
||||
this.subclassRepository = subclassRepository;
|
||||
}
|
||||
|
||||
public IReadOnlyList<DataSerie> GetExpensesPerMonth()
|
||||
{
|
||||
var transactions = viewService.GetTransactionViewList().Where(a => a.TargetAccount.Class == ClassIds.Expenses);
|
||||
var expenseTransactions = viewService.GetTransactionViewList()
|
||||
.Where(t => t.TargetAccount.Class == ClassIds.Expenses)
|
||||
.ToList();
|
||||
|
||||
// More than one subclass xx
|
||||
if (transactions.Select(t => t.TargetAccount.SubClass).Distinct().Count() > 1)
|
||||
{
|
||||
return GetSubClassExpensesPerMonth();
|
||||
var distinctSubClasses = expenseTransactions
|
||||
.Select(t => t.TargetAccount.SubClass)
|
||||
.Distinct()
|
||||
.Count();
|
||||
|
||||
return distinctSubClasses > 1
|
||||
? GetSubClassExpensesPerMonth(expenseTransactions)
|
||||
: GetAccountExpensesPerMonth();
|
||||
}
|
||||
|
||||
// only one subclass xx (z.B. Mandant Mannenbach)
|
||||
return GetAccountExpensesPerMonth();
|
||||
}
|
||||
|
||||
private List<DataSerie> GetSubClassExpensesPerMonth()
|
||||
private List<DataSerie> GetSubClassExpensesPerMonth(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 maxMonth = expenseTransactions
|
||||
.OrderByDescending(t => t.ValueDate)
|
||||
.FirstOrDefault()?.ValueDate.Month ?? 12;
|
||||
|
||||
// Group by subclass
|
||||
foreach (var grp in transactions.GroupBy(a => a.TargetAccount.SubClass).Select(a => new {Key = a.Key, List = a.ToList()}))
|
||||
var list = new List<DataSerie>();
|
||||
foreach (var grp in expenseTransactions.GroupBy(t => t.TargetAccount.SubClass))
|
||||
{
|
||||
// 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));
|
||||
var groupedByMonth = grp
|
||||
.GroupBy(t => t.ValueDate.Month)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(t => t.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,
|
||||
list.Add(new DataSerie(
|
||||
Id: grp.Key.ToString(),
|
||||
Name: grp.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 year = settingsService.GetYear();
|
||||
var allTransactions = viewService.GetTransactionViewList();
|
||||
var expenseAccounts = viewService.GetAccountViewList().Where(a => a.Class == ClassIds.Expenses);
|
||||
|
||||
var list = new List<DataSerie>();
|
||||
var expensesAccounts = viewService.GetAccountViewList().Where(a => a.Class == ClassIds.Expenses);
|
||||
foreach (var account in expensesAccounts)
|
||||
foreach (var account in expenseAccounts)
|
||||
{
|
||||
var serie = GetAccountExpensesPerMonth(account.Id, DateTime.Now.Year);
|
||||
if (serie != null)
|
||||
var transactions = allTransactions.Where(t => t.TargetAccountId == account.Id).ToList();
|
||||
if (transactions.Count == 0)
|
||||
{
|
||||
list.Add(serie);
|
||||
continue;
|
||||
}
|
||||
|
||||
var maxMonth = transactions.Max(t => t.ValueDate.Month);
|
||||
var groupedByMonth = transactions
|
||||
.GroupBy(t => t.ValueDate.Month)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(t => t.Value));
|
||||
EnsureEntryForEveryMonth(groupedByMonth, maxMonth);
|
||||
|
||||
list.Add(new DataSerie(
|
||||
Id: account.Id,
|
||||
Name: account.Name,
|
||||
X: groupedByMonth.OrderBy(g => g.Key).Select(g => new DateOnly(year, g.Key, 1)).ToList(),
|
||||
Y: groupedByMonth.OrderBy(g => g.Key).Select(g => g.Value).ToList()
|
||||
));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private DataSerie GetAccountExpensesPerMonth(string accountId, int year)
|
||||
{
|
||||
if (settingsService.TrySetYear(year))
|
||||
{
|
||||
var accountList = accountRepository.GetAccountList();
|
||||
var account = accountList.SingleOrDefault(a => a.Id == accountId);
|
||||
// perhaps we dont have the account for last year
|
||||
if (account == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var transactions = viewService.GetTransactionViewList().Where(t => t.TargetAccountId == accountId).ToList();
|
||||
|
||||
var newestTransaction = transactions.OrderByDescending(t => t.ValueDate).FirstOrDefault();
|
||||
var maxMonth = newestTransaction?.ValueDate.Month ?? 12;
|
||||
|
||||
var groupedByMonth = transactions.GroupBy(g => g.ValueDate.Month).ToDictionary(g => g.Key, g => g.ToList().Sum(s => s.Value));
|
||||
EnsureEntryForEveryMonth(groupedByMonth, maxMonth);
|
||||
|
||||
settingsService.SetYear(DateTime.Now.Year);
|
||||
|
||||
return new DataSerie(
|
||||
Id: accountId,
|
||||
Name: account.Name,
|
||||
X: groupedByMonth.OrderBy(g => g.Key).Select(g => new DateOnly(year, g.Key, 1)).ToList(),
|
||||
Y: groupedByMonth.OrderBy(g => g.Key).Select(g => g.Value).ToList()
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void EnsureEntryForEveryMonth(Dictionary<int, decimal> values, int maxMonth = 12)
|
||||
private static void EnsureEntryForEveryMonth(Dictionary<int, decimal> values, int maxMonth = 12)
|
||||
{
|
||||
for (int i = 1; i <= maxMonth; i++)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ namespace Schaad.Accounting.Services
|
|||
|
||||
public decimal ConvertCurrency(decimal amount, string fromCurrency, string toCurrency, string fixerIoApiKey)
|
||||
{
|
||||
if (currencies.Contains(fromCurrency) && currencies.Contains(fromCurrency))
|
||||
if (currencies.Contains(fromCurrency) && currencies.Contains(toCurrency))
|
||||
{
|
||||
return amount;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
|
@ -130,28 +129,22 @@ namespace Schaad.Accounting.Services
|
|||
{
|
||||
var credit = "";
|
||||
var debit = "";
|
||||
var signedValue = trx.Value;
|
||||
|
||||
if (trx.OriginAccountId == accountId)
|
||||
{
|
||||
debit = ToFormattedString(trx.Value);
|
||||
trx.Value *= -1;
|
||||
debit = trx.Value.ToFormattedString();
|
||||
signedValue = -trx.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
credit = ToFormattedString(trx.Value);
|
||||
credit = trx.Value.ToFormattedString();
|
||||
}
|
||||
balance += trx.Value;
|
||||
sb.AppendLine($"{trx.BookingDate:dd.MM.yyyy};{trx.ValueDate:dd.MM.yyyy};{trx.Text};{debit};{credit};{ToFormattedString(balance)}");
|
||||
balance += signedValue;
|
||||
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());
|
||||
return fileBytes;
|
||||
}
|
||||
|
||||
private string ToFormattedString(decimal value)
|
||||
{
|
||||
var culture = new CultureInfo("de-CH");
|
||||
culture.NumberFormat.NumberGroupSeparator = "'";
|
||||
return value.ToString("#,0.00", culture);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FreeSpire.PDF" Version="10.2.0" />
|
||||
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ namespace Schaad.Accounting.Services
|
|||
|
||||
public bool TrySetYear(int year)
|
||||
{
|
||||
var oldYear = year;
|
||||
var oldYear = this.year;
|
||||
SetYear(year);
|
||||
if (Directory.GetFiles(GetDbPath()).Any() == false)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -57,22 +57,43 @@ namespace Schaad.Accounting.Services
|
|||
var subClassNameByNumber = subclassRepository.GetSubClassList().ToDictionary(s => s.Number, s => s.Name);
|
||||
var settings = settingsService.GetSettings();
|
||||
|
||||
var accountList = accounts.Select(
|
||||
a =>
|
||||
new AccountDataset(
|
||||
// Group transactions by account so per-account balance is O(k) instead of O(M).
|
||||
var creditsByAccount = transactionList.GroupBy(t => t.TargetAccountId).ToDictionary(g => g.Key, g => g.ToList());
|
||||
var debitsByAccount = transactionList.GroupBy(t => t.OriginAccountId).ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var accountList = accounts.Select(a =>
|
||||
{
|
||||
var balance = ComputeBalance(a, creditsByAccount, debitsByAccount);
|
||||
return new AccountDataset(
|
||||
account: a,
|
||||
balance: GetBalanceInAccountCurrency(a, transactionList),
|
||||
balanceCHF: GetCurrentBalanceInCHF(a, transactionList),
|
||||
balance: balance,
|
||||
balanceCHF: fxService.ConvertCurrency(balance, a.Currency, "CHF", settings.FixerIoApiKey),
|
||||
startBalanceCHF: fxService.ConvertCurrency(a.StartBalance, a.Currency, "CHF", settings.FixerIoApiKey),
|
||||
className: subclassRepository.GetClass(a.Class),
|
||||
subClassName: subClassNameByNumber[a.SubClass]
|
||||
)
|
||||
)
|
||||
subClassName: subClassNameByNumber[a.SubClass]);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return accountList.OrderBy(a => a.Number).ToList();
|
||||
}
|
||||
|
||||
private static decimal ComputeBalance(
|
||||
Account account,
|
||||
Dictionary<string, List<TransactionDataset>> creditsByAccount,
|
||||
Dictionary<string, List<TransactionDataset>> debitsByAccount)
|
||||
{
|
||||
var balance = account.StartBalance;
|
||||
if (creditsByAccount.TryGetValue(account.Id, out var credits))
|
||||
{
|
||||
balance += credits.Sum(t => t.GetValue(account.IsFxAccount));
|
||||
}
|
||||
if (debitsByAccount.TryGetValue(account.Id, out var debits))
|
||||
{
|
||||
balance -= debits.Sum(t => t.GetValue(account.IsFxAccount));
|
||||
}
|
||||
return balance;
|
||||
}
|
||||
|
||||
public BalanceDataset GetBalanceView()
|
||||
{
|
||||
var accountList = GetAccountViewList();
|
||||
|
|
@ -107,40 +128,20 @@ namespace Schaad.Accounting.Services
|
|||
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;
|
||||
}
|
||||
|
||||
private decimal GetCurrentBalanceInCHF(Account a, List<TransactionDataset> transactionList)
|
||||
{
|
||||
var settings = settingsService.GetSettings();
|
||||
var balanceInAccountCurrency = GetBalanceInAccountCurrency(a, transactionList);
|
||||
var balanceInChf = fxService.ConvertCurrency(balanceInAccountCurrency, a.Currency, "CHF", settings.FixerIoApiKey);
|
||||
return balanceInChf;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get transaction list with the origin and target account for each transaction
|
||||
/// </summary>
|
||||
public List<TransactionDataset> GetTransactionViewList()
|
||||
{
|
||||
var accountList = accountRepository.GetAccountList();
|
||||
var accountsById = accountRepository.GetAccountList().ToDictionary(a => a.Id);
|
||||
var transactionList = transactionRepository.GetTransactionList();
|
||||
|
||||
var transactionViewList = transactionList.Select(
|
||||
t =>
|
||||
return transactionList.Select(t =>
|
||||
new TransactionDataset(
|
||||
t,
|
||||
accountList.Single(a => a.Id == t.OriginAccountId),
|
||||
accountList.Single(a => a.Id == t.TargetAccountId))
|
||||
)
|
||||
accountsById[t.OriginAccountId],
|
||||
accountsById[t.TargetAccountId]))
|
||||
.ToList();
|
||||
|
||||
return transactionViewList;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -148,50 +149,41 @@ namespace Schaad.Accounting.Services
|
|||
/// </summary>
|
||||
public List<TransactionDataset> GetTransactionViewList(string accountId)
|
||||
{
|
||||
var accountList = accountRepository.GetAccountList();
|
||||
var transactionList = transactionRepository.GetTransactionList().Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId);
|
||||
var accountsById = accountRepository.GetAccountList().ToDictionary(a => a.Id);
|
||||
var transactionList = transactionRepository.GetTransactionList()
|
||||
.Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId);
|
||||
|
||||
var transactionViewList = transactionList.Select(
|
||||
t =>
|
||||
return transactionList.Select(t =>
|
||||
new TransactionDataset(
|
||||
Prepare(t),
|
||||
accountList.Single(a => a.Id == t.OriginAccountId),
|
||||
accountList.Single(a => a.Id == t.TargetAccountId)
|
||||
)
|
||||
)
|
||||
WithDisplaySign(t),
|
||||
accountsById[t.OriginAccountId],
|
||||
accountsById[t.TargetAccountId]))
|
||||
.ToList();
|
||||
|
||||
return transactionViewList;
|
||||
|
||||
Transaction Prepare(Transaction t)
|
||||
Transaction WithDisplaySign(Transaction t)
|
||||
{
|
||||
var account = accountList.Single(a => a.Id == t.OriginAccountId);
|
||||
|
||||
if (account.Class == ClassIds.Activa && accountId == t.OriginAccountId )
|
||||
var originAccount = accountsById[t.OriginAccountId];
|
||||
if (originAccount.Class == ClassIds.Activa && accountId == t.OriginAccountId)
|
||||
{
|
||||
t.Value *= -1;
|
||||
var copy = new Transaction();
|
||||
t.Copy(copy);
|
||||
copy.Value *= -1;
|
||||
return copy;
|
||||
}
|
||||
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Get booking rules with their account
|
||||
/// </summary>
|
||||
public List<BookingRuleDataset> GetBookingRuleViewList()
|
||||
{
|
||||
var accountList = accountRepository.GetAccountList();
|
||||
var bookinRuleList = bookingRuleRepository.GetBookingRuleList();
|
||||
var accountsById = accountRepository.GetAccountList().ToDictionary(a => a.Id);
|
||||
var bookingRules = bookingRuleRepository.GetBookingRuleList();
|
||||
|
||||
return bookinRuleList.Select(
|
||||
t =>
|
||||
new BookingRuleDataset(
|
||||
t,
|
||||
accountList.Single(a => a.Id == t.AccountId).Name
|
||||
)
|
||||
)
|
||||
return bookingRules.Select(t =>
|
||||
new BookingRuleDataset(t, accountsById[t.AccountId].Name))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
|
|
|||
112
Schaad.Accounting.Tests/AccountRepositoryTestShould.cs
Normal file
112
Schaad.Accounting.Tests/AccountRepositoryTestShould.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
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());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(dbDir))
|
||||
{
|
||||
Directory.Delete(dbDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AssignNewIdWhenSavingAccountWithoutId()
|
||||
{
|
||||
var account = new Account { Number = 1000, Name = "Cash", Currency = "CHF" };
|
||||
|
||||
sut.SaveAccount(account);
|
||||
|
||||
var stored = sut.GetAccountList().Single();
|
||||
stored.Id.ShouldNotBeNullOrEmpty();
|
||||
stored.Name.ShouldBe("Cash");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateInPlaceWhenSavingAccountWithExistingId()
|
||||
{
|
||||
sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
|
||||
var id = sut.GetAccountList().Single().Id;
|
||||
|
||||
sut.SaveAccount(new Account { Id = id, Number = 1000, Name = "Cash renamed", Currency = "CHF" });
|
||||
|
||||
var accounts = sut.GetAccountList();
|
||||
accounts.Count.ShouldBe(1);
|
||||
accounts[0].Name.ShouldBe("Cash renamed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultCurrencyToChfWhenSavingAccountWithoutCurrency()
|
||||
{
|
||||
sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = null! });
|
||||
|
||||
sut.GetAccountList().Single().Currency.ShouldBe("CHF");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveAccountWhenDeletingById()
|
||||
{
|
||||
sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
|
||||
var id = sut.GetAccountList().Single().Id;
|
||||
|
||||
sut.DeleteAccount(id);
|
||||
|
||||
sut.GetAccountList().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnAccountWhenLookingUpByBankAccountNumber()
|
||||
{
|
||||
sut.SaveAccount(new Account { Number = 1010, Name = "Checking", Currency = "CHF", BankAccountNumber = "CH01" });
|
||||
sut.SaveAccount(new Account { Number = 1020, Name = "Savings", Currency = "CHF", BankAccountNumber = "CH02" });
|
||||
|
||||
sut.GetAccountForBankAccountNumber("CH01").Name.ShouldBe("Checking");
|
||||
sut.GetAccountForBankAccountNumber("CH02").Name.ShouldBe("Savings");
|
||||
sut.GetAccountForBankAccountNumber("CH99").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateLastBankBalanceWhenSavingBankBalance()
|
||||
{
|
||||
sut.SaveAccount(new Account { Number = 1010, Name = "Checking", Currency = "CHF", BankAccountNumber = "CH01" });
|
||||
|
||||
sut.SaveBankAccountBalance("CH01", 1234.56m);
|
||||
|
||||
sut.GetAccountForBankAccountNumber("CH01").LastBankBalance.ShouldBe(1234.56m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SurviveConstructorReRunWhenAccountsFileAlreadyExists()
|
||||
{
|
||||
sut.SaveAccount(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());
|
||||
|
||||
var accounts = fresh.GetAccountList();
|
||||
accounts.Count.ShouldBe(1);
|
||||
accounts[0].Name.ShouldBe("Cash");
|
||||
}
|
||||
}
|
||||
132
Schaad.Accounting.Tests/ChartServiceTestShould.cs
Normal file
132
Schaad.Accounting.Tests/ChartServiceTestShould.cs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
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 void HonourSelectedYearWhenBuildingMonthlySeries()
|
||||
{
|
||||
settingsService.GetYear().Returns(2023);
|
||||
var groceries = MakeAccount("groc", 4200);
|
||||
var checking = MakeAccount("chk", 1010);
|
||||
viewService.GetAccountViewList().Returns(new List<AccountDataset> { AsDataset(groceries), AsDataset(checking) });
|
||||
viewService.GetTransactionViewList().Returns(new List<TransactionDataset>
|
||||
{
|
||||
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2023, 4, 5))
|
||||
});
|
||||
|
||||
var series = BuildService().GetExpensesPerMonth();
|
||||
|
||||
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 void GroupExpensesByAccountWhenAllExpensesShareOneSubClass()
|
||||
{
|
||||
var mannenbach = MakeAccount("m1", 4000); // SubClass 40
|
||||
var mannenbach2 = MakeAccount("m2", 4001); // SubClass 40 (same)
|
||||
var checking = MakeAccount("chk", 1010);
|
||||
viewService.GetAccountViewList().Returns(new List<AccountDataset>
|
||||
{
|
||||
AsDataset(mannenbach), AsDataset(mannenbach2), AsDataset(checking)
|
||||
});
|
||||
viewService.GetTransactionViewList().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 = BuildService().GetExpensesPerMonth();
|
||||
|
||||
series.Count.ShouldBe(2);
|
||||
series.ShouldContain(s => s.Id == "m1");
|
||||
series.ShouldContain(s => s.Id == "m2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupExpensesBySubClassWhenMultipleSubClassesAreUsed()
|
||||
{
|
||||
var groceries = MakeAccount("groc", 4200); // SubClass 42
|
||||
var utilities = MakeAccount("util", 4300); // SubClass 43
|
||||
var checking = MakeAccount("chk", 1010);
|
||||
viewService.GetAccountViewList().Returns(new List<AccountDataset>
|
||||
{
|
||||
AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
|
||||
});
|
||||
viewService.GetTransactionViewList().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 = BuildService().GetExpensesPerMonth();
|
||||
|
||||
series.Count.ShouldBe(2);
|
||||
series.ShouldContain(s => s.Id == "42"); // sub-class number as id
|
||||
series.ShouldContain(s => s.Id == "43");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkipAccountsWithoutTransactionsWhenBuildingSeries()
|
||||
{
|
||||
var groceries = MakeAccount("groc", 4000); // has activity
|
||||
var utilities = MakeAccount("util", 4001); // same subclass, no activity
|
||||
var checking = MakeAccount("chk", 1010);
|
||||
viewService.GetAccountViewList().Returns(new List<AccountDataset>
|
||||
{
|
||||
AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
|
||||
});
|
||||
viewService.GetTransactionViewList().Returns(new List<TransactionDataset>
|
||||
{
|
||||
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 1))
|
||||
});
|
||||
|
||||
var series = BuildService().GetExpensesPerMonth();
|
||||
|
||||
series.Count.ShouldBe(1);
|
||||
series[0].Id.ShouldBe("groc");
|
||||
}
|
||||
|
||||
private static Account MakeAccount(string id, int number) => new()
|
||||
{
|
||||
Id = id,
|
||||
Number = number,
|
||||
Name = id,
|
||||
Currency = "CHF"
|
||||
};
|
||||
|
||||
private static AccountDataset AsDataset(Account a) =>
|
||||
new(a, balance: 0, balanceCHF: 0, startBalanceCHF: 0, className: "", subClassName: "");
|
||||
|
||||
private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, DateTime valueDate) =>
|
||||
new(
|
||||
new Transaction
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
OriginAccountId = origin.Id,
|
||||
TargetAccountId = target.Id,
|
||||
Value = value,
|
||||
ValueDate = valueDate,
|
||||
BookingDate = valueDate
|
||||
},
|
||||
origin, target);
|
||||
}
|
||||
106
Schaad.Accounting.Tests/FileServiceTestShould.cs
Normal file
106
Schaad.Accounting.Tests/FileServiceTestShould.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
using System.Text;
|
||||
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()
|
||||
{
|
||||
// Registering the ISO-8859-1 code page provider so the CSV encoding call works
|
||||
// in the test host (which does not include legacy encodings by default).
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
}
|
||||
|
||||
private FileService BuildService() => new(
|
||||
settingsService, accountRepo, transactionRepo, bankTransactionRepo,
|
||||
accountStatementService, creditCardStatementService);
|
||||
|
||||
[Fact]
|
||||
public void EmitHeaderRowAndRunningBalanceWhenExportingTransactionsCsv()
|
||||
{
|
||||
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF", StartBalance = 1000m };
|
||||
var groceries = new Account { Id = "groc", Number = 4200, Name = "Groceries", Currency = "CHF" };
|
||||
var salary = new Account { Id = "sal", Number = 3000, Name = "Salary", Currency = "CHF" };
|
||||
accountRepo.GetAccount("chk").Returns(checking);
|
||||
transactionRepo.GetTransactionList().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 = BuildService().GetTransactionListCsv("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");
|
||||
// Debit line (outgoing) — value in Belastung column, balance drops
|
||||
lines[2].ShouldBe("05.01.2026;05.01.2026;Coop;50.00;;950.00");
|
||||
// Credit line (incoming) — value in Gutschrift column, balance rises
|
||||
lines[3].ShouldBe("25.01.2026;25.01.2026;Salary;;5'000.00;5'950.00");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderTransactionsByBookingDateThenValueDateThenValueWhenExporting()
|
||||
{
|
||||
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF", StartBalance = 0m };
|
||||
accountRepo.GetAccount("chk").Returns(checking);
|
||||
transactionRepo.GetTransactionList().Returns(new List<Transaction>
|
||||
{
|
||||
// Deliberately unordered
|
||||
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(BuildService().GetTransactionListCsv("chk"));
|
||||
var lines = csv.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// Skip header + start balance rows
|
||||
lines[2].ShouldContain("earlier small");
|
||||
lines[3].ShouldContain("earlier large");
|
||||
lines[4].ShouldContain("later");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotMutateTransactionValueWhenExportingCsv()
|
||||
{
|
||||
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF" };
|
||||
accountRepo.GetAccount("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.GetTransactionList().Returns(new List<Transaction> { outgoing });
|
||||
|
||||
BuildService().GetTransactionListCsv("chk");
|
||||
|
||||
// Regression: earlier code did `trx.Value *= -1` mid-loop.
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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 void CallLoaderOnceWhenKeyIsRequestedRepeatedly()
|
||||
{
|
||||
var cache = new RepositoryCache();
|
||||
var loaderCalls = 0;
|
||||
|
||||
var first = cache.GetOrLoad("key", () => { loaderCalls++; return new List<int> { 1, 2 }; });
|
||||
var second = cache.GetOrLoad("key", () => { loaderCalls++; return new List<int> { 99 }; });
|
||||
|
||||
loaderCalls.ShouldBe(1);
|
||||
second.ShouldBeSameAs(first);
|
||||
second.ShouldBe(new[] { 1, 2 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeepEntriesSeparateWhenKeysDiffer()
|
||||
{
|
||||
var cache = new RepositoryCache();
|
||||
|
||||
var a = cache.GetOrLoad("a", () => new List<int> { 1 });
|
||||
var b = cache.GetOrLoad("b", () => new List<int> { 2 });
|
||||
|
||||
b.ShouldNotBeSameAs(a);
|
||||
a.ShouldBe(new[] { 1 });
|
||||
b.ShouldBe(new[] { 2 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReloadFromLoaderWhenKeyIsInvalidated()
|
||||
{
|
||||
var cache = new RepositoryCache();
|
||||
var loaderCalls = 0;
|
||||
|
||||
cache.GetOrLoad("k", () => { loaderCalls++; return new List<int> { 1 }; });
|
||||
cache.Invalidate("k");
|
||||
cache.GetOrLoad("k", () => { loaderCalls++; return new List<int> { 2 }; });
|
||||
|
||||
loaderCalls.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchKeysCaseInsensitivelyWhenLookingUp()
|
||||
{
|
||||
var cache = new RepositoryCache();
|
||||
|
||||
cache.GetOrLoad("Path/To/File.xml", () => new List<int> { 1 });
|
||||
var upper = cache.GetOrLoad("PATH/TO/FILE.XML", () => 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>
|
||||
138
Schaad.Accounting.Tests/TransactionRepositoryTestShould.cs
Normal file
138
Schaad.Accounting.Tests/TransactionRepositoryTestShould.cs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
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);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(dbDir))
|
||||
{
|
||||
Directory.Delete(dbDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreserveValueWhenRoundTrippingNonFxTransaction()
|
||||
{
|
||||
accountRepo.GetAccount("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
|
||||
accountRepo.GetAccount("groc").Returns(new Account { Id = "groc", Currency = "CHF" });
|
||||
|
||||
sut.SaveTransaction(new Transaction
|
||||
{
|
||||
OriginAccountId = "chk",
|
||||
TargetAccountId = "groc",
|
||||
Value = 42m,
|
||||
Text = "Groceries",
|
||||
ValueDate = new DateTime(2026, 5, 10),
|
||||
BookingDate = new DateTime(2026, 5, 10)
|
||||
});
|
||||
|
||||
var stored = sut.GetTransactionList().Single();
|
||||
var loaded = sut.GetTransaction(stored.Id);
|
||||
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.Value.ShouldBe(42m);
|
||||
loaded.FxRate.ShouldBeNull(); // Non-FX transactions have FxRate cleared
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StoreInChfAndReturnInAccountCurrencyWhenTransactionIsFx()
|
||||
{
|
||||
// User enters 100 USD at rate 0.9 CHF/USD -> stored as 90 CHF; read back as 100 USD.
|
||||
accountRepo.GetAccount("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
|
||||
accountRepo.GetAccount("usd").Returns(new Account { Id = "usd", Currency = "USD" });
|
||||
|
||||
sut.SaveTransaction(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 = sut.GetTransactionList().Single();
|
||||
stored.Value.ShouldBe(90m); // stored in CHF
|
||||
|
||||
var loaded = sut.GetTransaction(stored.Id);
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.Value.ShouldBe(100m); // display currency
|
||||
loaded.FxRate.ShouldBe(0.9m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnNullWhenGettingUnknownTransactionId()
|
||||
{
|
||||
sut.GetTransaction("no-such-id").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotMutateStoredValueWhenGettingFxTransactionTwice()
|
||||
{
|
||||
accountRepo.GetAccount("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
|
||||
accountRepo.GetAccount("usd").Returns(new Account { Id = "usd", Currency = "USD" });
|
||||
|
||||
sut.SaveTransaction(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 = sut.GetTransactionList().Single();
|
||||
var id = stored.Id;
|
||||
|
||||
// Get twice; if Get mutated the stored entity, the second call would divide by FxRate again.
|
||||
var first = sut.GetTransaction(id);
|
||||
var second = sut.GetTransaction(id);
|
||||
|
||||
first.ShouldNotBeNull();
|
||||
first.Value.ShouldBe(100m);
|
||||
second.ShouldNotBeNull();
|
||||
second.Value.ShouldBe(100m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveTransactionWhenDeletingById()
|
||||
{
|
||||
accountRepo.GetAccount(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
|
||||
|
||||
sut.SaveTransaction(new Transaction { OriginAccountId = "a", TargetAccountId = "b", Value = 1m, Text = "x", ValueDate = DateTime.Today });
|
||||
var id = sut.GetTransactionList().Single().Id;
|
||||
|
||||
sut.DeleteTransaction(id);
|
||||
|
||||
sut.GetTransactionList().ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
211
Schaad.Accounting.Tests/ViewServiceTestShould.cs
Normal file
211
Schaad.Accounting.Tests/ViewServiceTestShould.cs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
using NSubstitute;
|
||||
using Schaad.Accounting.Datasets;
|
||||
using Schaad.Accounting.Interfaces;
|
||||
using Schaad.Accounting.Models;
|
||||
using Schaad.Accounting.Services;
|
||||
using Schaad.Finance.Api;
|
||||
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 IFxService fxService = Substitute.For<IFxService>();
|
||||
private readonly ISettingsService settingsService = Substitute.For<ISettingsService>();
|
||||
|
||||
public ViewServiceTestShould()
|
||||
{
|
||||
// Cover every subclass number a test might reference (Account.SubClass = Number / 100).
|
||||
subclassRepo.GetSubClassList().Returns(
|
||||
Enumerable.Range(10, 50).Select(n => new SubClass { Number = n, Name = "sub-" + n }).ToList());
|
||||
subclassRepo.GetClass(Arg.Any<int>()).Returns("");
|
||||
settingsService.GetSettings().Returns(new SettingsDataset { DataPath = "", FixerIoApiKey = "" });
|
||||
|
||||
// Passthrough FX by default (CHF-only). Individual tests can override.
|
||||
fxService.ConvertCurrency(Arg.Any<decimal>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns(ci => ci.ArgAt<decimal>(0));
|
||||
}
|
||||
|
||||
private ViewService BuildService() =>
|
||||
new(accountRepo, bankTransactionRepo, transactionRepo, subclassRepo, bookingRuleRepo, fxService, settingsService);
|
||||
|
||||
// --- Balance math ---------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void 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.GetAccountList().Returns(new List<Account> { cash, receivables, payables });
|
||||
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
|
||||
|
||||
var view = BuildService().GetBalanceView();
|
||||
|
||||
view.ActivaAccountList.Count.ShouldBe(2);
|
||||
view.PassivaAccountList.Count.ShouldBe(1);
|
||||
view.TotalActivaCHF.ShouldBe(150m);
|
||||
view.TotalPassivaCHF.ShouldBe(200m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyTransactionsToStartBalanceWhenComputingAccountBalance()
|
||||
{
|
||||
var cash = Account("cash", 1000, "CHF", startBalance: 1000m);
|
||||
var groceries = Account("groc", 4000, "CHF");
|
||||
accountRepo.GetAccountList().Returns(new List<Account> { cash, groceries });
|
||||
|
||||
// Debit cash 50, credit groceries 50 (spending)
|
||||
transactionRepo.GetTransactionList().Returns(new List<Transaction>
|
||||
{
|
||||
new() { Id = "t1", OriginAccountId = "cash", TargetAccountId = "groc", Value = 50m, ValueDate = new DateTime(2026, 1, 1) },
|
||||
new() { Id = "t2", OriginAccountId = "cash", TargetAccountId = "groc", Value = 30m, ValueDate = new DateTime(2026, 1, 2) },
|
||||
});
|
||||
|
||||
var accounts = BuildService().GetAccountViewList();
|
||||
|
||||
accounts.Single(a => a.Id == "cash").Balance.ShouldBe(920m); // 1000 - 80
|
||||
accounts.Single(a => a.Id == "groc").Balance.ShouldBe(80m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertBalanceToChfWhenAccountUsesForeignCurrency()
|
||||
{
|
||||
var usd = Account("usd", 1050, "USD", startBalance: 100m);
|
||||
accountRepo.GetAccountList().Returns(new List<Account> { usd });
|
||||
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
|
||||
|
||||
// 1 USD = 0.90 CHF
|
||||
fxService.ConvertCurrency(Arg.Any<decimal>(), "USD", "CHF", Arg.Any<string>())
|
||||
.Returns(ci => ci.ArgAt<decimal>(0) * 0.9m);
|
||||
|
||||
var account = BuildService().GetAccountViewList().Single();
|
||||
|
||||
account.Balance.ShouldBe(100m); // account currency
|
||||
account.BalanceCHF.ShouldBe(90m); // FX-converted
|
||||
account.StartBalanceCHF.ShouldBe(90m);
|
||||
}
|
||||
|
||||
// --- Bank-transaction auto-matching --------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void MatchBankTransactionByBookingRuleWhenLookupTextIsContained()
|
||||
{
|
||||
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
|
||||
var utilities = Account("util", 4200, "CHF");
|
||||
accountRepo.GetAccountList().Returns(new List<Account> { checking, utilities });
|
||||
bookingRuleRepo.GetBookingRuleList().Returns(new List<BookingRule>
|
||||
{
|
||||
new() { Id = "r1", LookupText = "electric", AccountId = "util", BookingText = "Strom" }
|
||||
});
|
||||
bankTransactionRepo.GetBankTransactionList().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.GetTransactionList().Returns(new List<Transaction>());
|
||||
|
||||
var matched = BuildService().MatchOpenBankTransactions().Single();
|
||||
|
||||
matched.Text.ShouldBe("Strom");
|
||||
// negative value => bank account is origin; rule fills the target
|
||||
matched.OriginAccountId.ShouldBe("chk");
|
||||
matched.TargetAccountId.ShouldBe("util");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreferBookingRuleWithMatchingValueWhenMultipleRulesMatchText()
|
||||
{
|
||||
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
|
||||
var rent = Account("rent", 4100, "CHF");
|
||||
var groceries = Account("groc", 4200, "CHF");
|
||||
accountRepo.GetAccountList().Returns(new List<Account> { checking, rent, groceries });
|
||||
bookingRuleRepo.GetBookingRuleList().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.GetBankTransactionList().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.GetTransactionList().Returns(new List<Transaction>());
|
||||
|
||||
var matched = BuildService().MatchOpenBankTransactions().Single();
|
||||
|
||||
matched.Text.ShouldBe("Rent via Coop");
|
||||
matched.TargetAccountId.ShouldBe("rent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FallBackToSameAccountsLastMonthWhenNoBookingRuleMatches()
|
||||
{
|
||||
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
|
||||
var salary = Account("sal", 3000, "CHF");
|
||||
accountRepo.GetAccountList().Returns(new List<Account> { checking, salary });
|
||||
bookingRuleRepo.GetBookingRuleList().Returns(new List<BookingRule>());
|
||||
bankTransactionRepo.GetBankTransactionList().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) }
|
||||
});
|
||||
// A prior-month booking with same accounts + value should be picked up
|
||||
transactionRepo.GetTransactionList().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 = BuildService().MatchOpenBankTransactions().Single();
|
||||
|
||||
matched.Text.ShouldBe("Monatslohn");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExcludeIgnoredAndAlreadyBookedWhenGettingOpenBankTransactions()
|
||||
{
|
||||
bankTransactionRepo.GetBankTransactionList().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.GetTransactionList().Returns(new List<Transaction>
|
||||
{
|
||||
new() { Id = "t1", BankTransactionId = "booked", OriginAccountId = "a", TargetAccountId = "b", Value = 10 }
|
||||
});
|
||||
|
||||
var open = BuildService().GetOpenBankTransactionList();
|
||||
|
||||
open.Count.ShouldBe(1);
|
||||
open[0].Id.ShouldBe("open");
|
||||
}
|
||||
|
||||
// --- Helpers -------------------------------------------------------------
|
||||
|
||||
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,5 @@
|
|||
using Microsoft.AspNetCore.Components;
|
||||
using Schaad.Accounting;
|
||||
using Schaad.Accounting.Datasets;
|
||||
using Schaad.Accounting.Interfaces;
|
||||
|
||||
|
|
@ -22,8 +23,8 @@ public partial class ProfitLossReport : ComponentBase
|
|||
protected override Task OnInitializedAsync()
|
||||
{
|
||||
accounts = viewService.GetAccountViewList();
|
||||
profit = Math.Abs(accounts.Where(m => m.Class == 3).Sum(m => m.Balance));
|
||||
loss = Math.Abs(accounts.Where(m => m.Class == 4).Sum(m => m.Balance));
|
||||
profit = Math.Abs(accounts.Where(m => m.Class == ClassIds.Income).Sum(m => m.Balance));
|
||||
loss = Math.Abs(accounts.Where(m => m.Class == ClassIds.Expenses).Sum(m => m.Balance));
|
||||
win = profit-loss;
|
||||
|
||||
(header, footer) = Report.GetViewDataTitleAndFooter("Erfolgsrechnung", settingsService);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using System.Globalization;
|
||||
using Schaad.Accounting.Interfaces;
|
||||
using Schaad.Accounting.Interfaces;
|
||||
using Schaad.Accounting.Repositories;
|
||||
using Schaad.Accounting.Services;
|
||||
using Schaad.Finance.Api;
|
||||
|
|
@ -9,21 +8,20 @@ namespace Schaad.Accounting.UI
|
|||
{
|
||||
public static class Extensions
|
||||
{
|
||||
public static string ToFormattedString(this decimal value)
|
||||
{
|
||||
var culture = new CultureInfo("de-CH");
|
||||
culture.NumberFormat.NumberGroupSeparator = "'";
|
||||
return value.ToString("#,0.00", culture);
|
||||
}
|
||||
|
||||
public static IServiceCollection AddRepositories(this IServiceCollection services)
|
||||
public static IServiceCollection AddAccounting(this IServiceCollection services)
|
||||
{
|
||||
// Singleton because MyHeader triggers a full page reload (forceLoad: true) after
|
||||
// changing year/mandator; a Scoped instance would be recreated on the new circuit
|
||||
// and lose the selection. TODO: persist selection to a cookie/query string so this
|
||||
// can safely become Scoped (see IMPROVEMENT_PLAN.md).
|
||||
services.AddSingleton<ISettingsService, SettingsService>();
|
||||
|
||||
services.AddScoped<RepositoryCache>();
|
||||
services.AddScoped<IAccountRepository, AccountRepository>();
|
||||
services.AddScoped<IBankTransactionRepository, BankTransactionRepository>();
|
||||
services.AddScoped<IBookingRuleRepository, BookingRuleRepository>();
|
||||
services.AddScoped<IBookingTextRepository, BookingTextRepository>();
|
||||
services.AddScoped<ISplitPredefinitonRepository, SplitPredefinitonRepository>();
|
||||
services.AddScoped<ISplitPredefinitionRepository, SplitPredefinitionRepository>();
|
||||
services.AddScoped<ISubclassRepository, SubclassRepository>();
|
||||
services.AddScoped<ITransactionRepository, TransactionRepository>();
|
||||
|
||||
|
|
@ -37,18 +35,5 @@ namespace Schaad.Accounting.UI
|
|||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IChartService, ChartService>();
|
||||
services.AddScoped<IViewService, ViewService>();
|
||||
services.AddScoped<IFileService, FileService>();
|
||||
services.AddScoped<IAccountStatementService, AccountStatementService>();
|
||||
services.AddScoped<ICreditCardStatementService, CreditCardStatementService>();
|
||||
services.AddSingleton<IFxService, DummyFxService>();
|
||||
services.AddSingleton<PdfParsingService, PdfParsingService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Localization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.FluentUI.AspNetCore.Components;
|
||||
using Schaad.Accounting.Datasets;
|
||||
|
|
@ -23,7 +24,7 @@ namespace Schaad.Accounting.UI
|
|||
builder.Services.AddSingleton(sp =>
|
||||
sp.GetRequiredService<IOptions<SettingsDataset>>().Value);
|
||||
|
||||
builder.Services.AddRepositories().AddServices();
|
||||
builder.Services.AddAccounting();
|
||||
|
||||
|
||||
var app = builder.Build();
|
||||
|
|
@ -38,15 +39,19 @@ namespace Schaad.Accounting.UI
|
|||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
var supportedCultures = new[] { new CultureInfo("de-CH") };
|
||||
app.UseRequestLocalization(new RequestLocalizationOptions
|
||||
{
|
||||
DefaultRequestCulture = new RequestCulture(supportedCultures[0]),
|
||||
SupportedCultures = supportedCultures,
|
||||
SupportedUICultures = supportedCultures
|
||||
});
|
||||
|
||||
app.UseAntiforgery();
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
var culture = new CultureInfo("de-CH");
|
||||
CultureInfo.DefaultThreadCurrentCulture = culture;
|
||||
CultureInfo.DefaultThreadCurrentUICulture = culture;
|
||||
|
||||
app.Run();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue