Compare commits
No commits in common. "8926fb1e973ba6629717768432a991dc521d72a1" and "a16aedcfd880171a27dc6ad8c524460261c64117" have entirely different histories.
8926fb1e97
...
a16aedcfd8
34 changed files with 316 additions and 1369 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -41,9 +41,6 @@ ScaffoldingReadMe.txt
|
||||||
*~
|
*~
|
||||||
CodeCoverage/
|
CodeCoverage/
|
||||||
|
|
||||||
# JetBrains Rider / ReSharper user-specific settings
|
|
||||||
*.DotSettings.user
|
|
||||||
|
|
||||||
# MSBuild Binary and Structured Log
|
# MSBuild Binary and Structured Log
|
||||||
*.binlog
|
*.binlog
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,78 +11,28 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Services"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Db", "Schaad.Accounting.Db\Schaad.Accounting.Db.csproj", "{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Db", "Schaad.Accounting.Db\Schaad.Accounting.Db.csproj", "{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Tests", "Schaad.Accounting.Tests\Schaad.Accounting.Tests.csproj", "{C0A48984-3770-49D7-9371-25351B4E79FF}"
|
|
||||||
EndProject
|
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
Debug|x64 = Debug|x64
|
|
||||||
Debug|x86 = Debug|x86
|
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
Release|x64 = Release|x64
|
|
||||||
Release|x86 = Release|x86
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|Any CPU.Build.0 = Release|Any CPU
|
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|Any CPU.Build.0 = Release|Any CPU
|
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|Any CPU.Build.0 = Release|Any CPU
|
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|Any CPU.Build.0 = Release|Any CPU
|
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{C0A48984-3770-49D7-9371-25351B4E79FF}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|
|
||||||
32
CLAUDE.md
32
CLAUDE.md
|
|
@ -11,14 +11,11 @@ dotnet build
|
||||||
# Run (dev server at http://localhost:5225)
|
# Run (dev server at http://localhost:5225)
|
||||||
cd Schaad.Accounting.UI && dotnet run
|
cd Schaad.Accounting.UI && dotnet run
|
||||||
|
|
||||||
# Test
|
|
||||||
dotnet test
|
|
||||||
|
|
||||||
# Publish
|
# Publish
|
||||||
dotnet publish -c Release
|
dotnet publish -c Release
|
||||||
```
|
```
|
||||||
|
|
||||||
Tests live in `Schaad.Accounting.Tests` (xUnit + NSubstitute + Shouldly). There is no lint command.
|
There are no automated tests or lint commands.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -64,29 +61,4 @@ Pages live in `Schaad.Accounting.UI/Components/Pages/`. Each page typically has
|
||||||
|
|
||||||
### Domain constants
|
### Domain constants
|
||||||
|
|
||||||
`ClassIds` in `Schaad.Accounting.Common` defines the Swiss accounting chart-of-accounts classes: Activa=1, Passiva=2, Income=3, Expenses=4. These are the leading digit of an account number (accounts are 4-digit; `Account.Class = Number / 1000`).
|
`ClassIds` in `Schaad.Accounting.Common` defines the Swiss accounting chart-of-accounts classes: Activa=1000, Passiva=2000, Income=3000, Expenses=4000.
|
||||||
|
|
||||||
## Testing conventions
|
|
||||||
|
|
||||||
Tests live in `Schaad.Accounting.Tests` and use **xUnit** + **NSubstitute** (mocks) + **Shouldly** (assertions).
|
|
||||||
|
|
||||||
- **File and class name**: `<Subject>TestShould` — one file per subject under test. Examples: `ViewServiceTestShould.cs`, `TransactionRepositoryTestShould.cs`, `FormattingTestShould.cs`.
|
|
||||||
- **Test method name**: `DoThisWhenThat` — describes the behavior first, then the condition. Read together the class + method form a sentence:
|
|
||||||
- `ViewServiceTestShould.SumActivaAndPassivaTotalsSeparatelyWhenGettingBalanceView`
|
|
||||||
- `TransactionRepositoryTestShould.ReturnNullWhenGettingUnknownTransactionId`
|
|
||||||
- **Assertions**: use Shouldly (`value.ShouldBe(expected)`, `list.ShouldBeEmpty()`, `x.ShouldBeNull()`, `list.ShouldContain(...)`, `first.ShouldBeSameAs(second)`, etc.). Do **not** use xUnit `Assert.*`.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class FormattingTestShould
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void RoundToTwoDecimalsWhenValueHasMorePrecision()
|
|
||||||
{
|
|
||||||
1.234m.ToFormattedString().ShouldBe("1.23");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Repository tests that hit real XML I/O use a temp directory via `Path.GetTempPath()` and clean up in `IDisposable.Dispose`. Pure service tests use NSubstitute mocks for every dependency and don't touch the filesystem.
|
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
# AccountingNext — Technical Improvement Plan
|
|
||||||
|
|
||||||
Analysis and phased plan produced 2026-07-02. See conversation history for full context.
|
|
||||||
|
|
||||||
## Findings
|
|
||||||
|
|
||||||
### 1. Correctness bugs (must fix)
|
|
||||||
|
|
||||||
- **Shared mutable state across users.** `SettingsService` is registered as `Singleton` (`Schaad.Accounting.UI/Extensions.cs:21`) but holds mutable `year`/`mandator` fields (`Schaad.Accounting.Services/SettingsService.cs:13-14`). Any user switching year/mandator changes it for *all* connected Blazor Server users.
|
|
||||||
- **`TrySetYear` rollback is broken.** `SettingsService.cs:33` captures `var oldYear = year;` — that's the *parameter*, not `this.year`. The "rollback" restores the same value that just failed.
|
|
||||||
- **`ChartService` mutates a shared service.** `GetAccountExpensesPerMonth(accountId, year)` (`ChartService.cs:87`) calls `settingsService.TrySetYear(year)` then `SetYear(DateTime.Now.Year)` to reload prior-year data. Global mutation on a singleton — guaranteed to race in production.
|
|
||||||
- **`DummyFxService` has a typo.** `DummyFxService.cs:13` — `currencies.Contains(fromCurrency) && currencies.Contains(fromCurrency)` (both sides check `fromCurrency`). Also it silently returns the raw amount for supported currencies, so all "CHF conversions" are no-ops.
|
|
||||||
- **`TransactionRepository.GetTransaction` mutates the entity.** `TransactionRepository.cs:67-81` divides `Value` by `FxRate` on read, and `SaveTransaction` multiplies by `FxRate` on write. Also silently NREs when the id is not found (line 73 dereferences `transaction`).
|
|
||||||
- **`ViewService.GetTransactionViewList(accountId)` mutates transactions.** `ViewService.cs:172` flips `t.Value *= -1` on the loaded transaction. `FileService.GetTransactionListCsv` does the same (`FileService.cs:137`). These are the shared objects returned by the repository — later reads see wrong signs.
|
|
||||||
- **DI double-registration.** `Extensions.cs` registers `IChartService`, `IViewService`, `IFileService`, statement services, and `IFxService` in both `AddRepositories` and `AddServices`. Also `AddServices` registers `PdfParsingService` as its own key (`services.AddSingleton<PdfParsingService, PdfParsingService>()`) instead of `IPdfParsingService`.
|
|
||||||
- **Magic numbers instead of `ClassIds`.** `ProfitLossReport.razor.cs:25-26` uses `.Class == 3` / `.Class == 4`.
|
|
||||||
- **`ClassIds` doc/code mismatch.** `CLAUDE.md` states `Activa=1000, Passiva=2000, …`, but `ClassIds.cs` defines them as `1,2,3,4` (matches `Account.Class = Number / 1000`).
|
|
||||||
- **Non-atomic XML writes, no locking.** `BaseRepository.Save` writes directly to the target path. Interrupted writes corrupt data. Two concurrent saves interleave. No temp-file+rename, no `FileShare` lock.
|
|
||||||
- **`AccountRepository.EnsureFileExisits` calls `SaveAccount` in a loop.** Each call re-loads and re-serializes the whole account list.
|
|
||||||
- **`ProfitLossReport` / `BalanceSheetReport`** compute totals by re-summing balances that `ViewService` already computes — but with subtle differences. Two sources of truth for the same number.
|
|
||||||
|
|
||||||
### 2. Reliability / maintainability
|
|
||||||
|
|
||||||
- **No async I/O.** Every XML read/write is synchronous, blocking the SignalR hub thread in Blazor Server.
|
|
||||||
- **No caching.** A single page render calls `GetAccountList()` and `GetTransactionList()` many times. Every one is a fresh file read + XML deserialization.
|
|
||||||
- **O(N·M) lookups.** `ViewService.cs:138-139` — `accountList.Single(...)` inside a `Select` over all transactions. Should be a dictionary keyed by id.
|
|
||||||
- **No logging.** `ILogger` is not used anywhere.
|
|
||||||
- **No error surface.** Repositories throw or return `null`; UI dereferences with `!`.
|
|
||||||
- **Anemic domain model + `Copy` methods.** `Account.Copy`, `Transaction.Copy`, `SubClass.Copy` etc. are hand-rolled property copies used to merge edits into loaded entities.
|
|
||||||
- **Vendor coupling in the Domain project.** `Schaad.Accounting.Common` references `Schaad.Finance.Api.dll`. `IChartService`, `IFileService`, and `IViewService` live in `Common` and depend on `Schaad.Finance.Api` types.
|
|
||||||
- **Duplicated formatting logic.** `ToFormattedString(decimal)` exists in `Extensions.cs` and again in `FileService.cs`.
|
|
||||||
- **Culture setup in `Program.cs`** runs *after* `MapRazorComponents` and just sets `DefaultThreadCurrentCulture` globally. Should be `RequestLocalizationOptions` middleware.
|
|
||||||
- **Constructors doing I/O.** `AccountRepository`, `SubclassRepository`, `SplitPredefinitonRepository`, `BookingRuleRepository` call `EnsureFileExisits` in the constructor.
|
|
||||||
- **Typos leak into public API.** `EnsureFileExisits`, `SplitPredefinitonRepository`.
|
|
||||||
- **Dead / redundant code.** `AccountRepository.EnsureFileExisits` uses `new` to hide the base method; its `file.IndexOf("Accounts") > -1` guard is redundant. `Home.razor.cs:50` uses a `loaded` flag though `OnInitializedAsync` already runs once per instance. Commented-out `//var subclasses = subclassRepository.GetSubClassList();` in `ChartService`.
|
|
||||||
- **Legacy nuget packages.** All three library projects reference `System.Text.RegularExpressions 4.3.1`, `System.Xml.XmlSerializer 4.3.0`, `System.ComponentModel.Annotations 5.0.0` — legacy .NET Standard packages, redundant on `net9.0`.
|
|
||||||
- **`Common.csproj`** has `<Folder Include="Interfaces\Extensions\" />` for a folder that does not exist.
|
|
||||||
- **`ClassIds`** should be `static class`; currently instantiable.
|
|
||||||
|
|
||||||
### 3. Testability
|
|
||||||
|
|
||||||
- Zero tests, zero test project.
|
|
||||||
- Business rules (balance calculation, FX conversion, bank-transaction matching, CSV export, split logic) are entangled with mutable singletons and side effects on returned entities.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phased plan
|
|
||||||
|
|
||||||
### Phase 1 — Stop the bleeding (correctness, low churn)
|
|
||||||
|
|
||||||
1. ~~Change `ISettingsService` registration from `Singleton` to `Scoped`.~~ **Deferred.** `MyHeader.YearChanged` uses `NavigateTo(..., forceLoad: true)` after mutating settings, which tears down the SignalR circuit — a Scoped instance would be recreated with default values on the new circuit. Proper fix: persist the year/mandator selection to a cookie or query string, then Scoped becomes safe. Tracked as a new Phase 2 item.
|
|
||||||
2. Fix `TrySetYear` (capture `this.year` before overwriting). Defer the `ChartService` prior-year-loading pattern to Phase 3.
|
|
||||||
3. Fix `DummyFxService` typo (or delete the class and replace with a real `IFxService` implementation from `Schaad.Finance.Api`).
|
|
||||||
4. Remove mutation-on-read in `TransactionRepository.GetTransaction`, `ViewService.GetTransactionViewList(accountId)`, and `FileService.GetTransactionListCsv`.
|
|
||||||
5. Add a not-found guard to `TransactionRepository.GetTransaction`.
|
|
||||||
6. De-duplicate the DI registrations. One `AddAccounting()` extension called once in `Program.cs`.
|
|
||||||
7. Replace `.Class == 3/4` magic numbers with `ClassIds.*`. Update CLAUDE.md's incorrect ClassIds section.
|
|
||||||
8. Make XML writes atomic: write to `foo.xml.tmp` then `File.Move(..., overwrite: true)`. Wrap Load/Save in a per-file `SemaphoreSlim` (or a simple `lock`).
|
|
||||||
|
|
||||||
### Phase 2 — Structural cleanup
|
|
||||||
|
|
||||||
9. **Introduce a per-request unit of work / cache.** A scoped `IAccountingContext` that loads each XML file at most once per request and holds the deserialized lists.
|
|
||||||
10. Replace `Copy(target)` methods with a single merge-in-place pattern (or `record with`).
|
|
||||||
11. Extract shared formatting to a single `Formatting` helper.
|
|
||||||
12. Move `IFileService`, `IChartService`, `IViewService` out of `Common` (they depend on `Schaad.Finance.Api`). Common should have no vendor dependency.
|
|
||||||
13. Fix typos (`EnsureFileExists`, `SplitPredefinitionRepository`).
|
|
||||||
14. Move file existence bootstrapping out of constructors into a startup step (`IHostedService` or lazy first-use).
|
|
||||||
15. Fix `AccountRepository.EnsureFileExists` to compute the start-balance updates in memory and save once.
|
|
||||||
16. Remove legacy NuGet packages. Remove the stale `Interfaces\Extensions\` folder entry.
|
|
||||||
17. Set culture via `RequestLocalizationOptions` middleware.
|
|
||||||
18. Add `ILogger<T>` to services and repositories.
|
|
||||||
18b. Persist selected year and mandator across page reloads (cookie, query string, or `ProtectedLocalStorage`). Prerequisite for making `ISettingsService` `Scoped` (Phase 1 item 1, deferred).
|
|
||||||
|
|
||||||
### Phase 3 — Async & performance
|
|
||||||
|
|
||||||
19. Convert repository interfaces to async.
|
|
||||||
20. Cache the current view's data behind the scoped unit-of-work; invalidate on save.
|
|
||||||
21. Precompute `accountsById` and `subclassNameByNumber` dictionaries once per request.
|
|
||||||
22. Clean up `ChartService` prior-year loading pattern: introduce an explicit "load year data" helper instead of mutating `ISettingsService`.
|
|
||||||
|
|
||||||
### Phase 4 — Testability & safety net
|
|
||||||
|
|
||||||
23. Add a `Schaad.Accounting.Tests` xUnit project.
|
|
||||||
24. First tests: `ViewService.GetBalanceView`, `MatchBankTransactionByBookingRule` / `SameAccountsLastMonth`, `TransactionRepository` FX round-trip, `FileService.GetTransactionListCsv`.
|
|
||||||
25. Abstract the XML store (`IEntityStore<T>`) so tests use an in-memory store.
|
|
||||||
|
|
||||||
### Phase 5 — Nice-to-haves
|
|
||||||
|
|
||||||
26. Replace hand-written XML models with `record` types + source-generated serializers.
|
|
||||||
27. Inject Fixer.io key via `IOptions<FxSettings>` instead of threading through method params.
|
|
||||||
28. Prune unused Fluent UI packages.
|
|
||||||
29. Add a health/backup admin page.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PR breakdown
|
|
||||||
|
|
||||||
- **PR A** — Phase 1 items 1–7 + CLAUDE.md fix.
|
|
||||||
- **PR B** — Phase 1 item 8 (atomic writes + lock).
|
|
||||||
- **PR C** — Phase 2 mechanical cleanup: items 11, 13, 15, 16, 17.
|
|
||||||
- **PR D** — Phase 2 item 9: per-request unit of work / cache. Split off from PR C because it is invasive enough to warrant its own review.
|
|
||||||
- **PR E** — Phase 3 item 22: ChartService cleanup. Promoted ahead of async because it fixes an active correctness bug — `GetAccountExpensesPerMonth(accountId, year)` was mutating `ISettingsService` on the singleton to hop years, with the year parameter always hardcoded to `DateTime.Now.Year`, so the Spendings-over-time chart discarded the user's header year selection.
|
|
||||||
- **PR F** — Phase 3 item 21: precomputed dictionaries in `ViewService` to eliminate O(N·M) `Single(...)` scans and per-account transaction filtering.
|
|
||||||
- **Item 19 (async I/O)** — 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.
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
using System.Globalization;
|
|
||||||
|
|
||||||
namespace Schaad.Accounting
|
|
||||||
{
|
|
||||||
public static class Formatting
|
|
||||||
{
|
|
||||||
private static readonly CultureInfo SwissCulture = CreateSwissCulture();
|
|
||||||
|
|
||||||
public static string ToFormattedString(this decimal value)
|
|
||||||
=> value.ToString("#,0.00", SwissCulture);
|
|
||||||
|
|
||||||
private static CultureInfo CreateSwissCulture()
|
|
||||||
{
|
|
||||||
var culture = new CultureInfo("de-CH");
|
|
||||||
culture.NumberFormat.NumberGroupSeparator = "'";
|
|
||||||
return culture;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Schaad.Accounting.Models;
|
using Schaad.Accounting.Models;
|
||||||
|
|
||||||
namespace Schaad.Accounting.Interfaces
|
namespace Schaad.Accounting.Interfaces
|
||||||
{
|
{
|
||||||
public interface ISplitPredefinitionRepository
|
public interface ISplitPredefinitonRepository
|
||||||
{
|
{
|
||||||
List<SplitPredefinition> GetSplitPredefinitionList();
|
List<SplitPredefinition> GetSplitPredefinitionList();
|
||||||
|
|
||||||
|
|
@ -4,9 +4,17 @@
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<RootNamespace>Schaad.Accounting</RootNamespace>
|
<RootNamespace>Schaad.Accounting</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
||||||
|
<PackageReference Include="System.Xml.XmlSerializer" Version="4.3.0" />
|
||||||
|
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Reference Include="Schaad.Finance.Api">
|
<Reference Include="Schaad.Finance.Api">
|
||||||
<HintPath>..\dlls\Schaad.Finance.Api.dll</HintPath>
|
<HintPath>..\dlls\Schaad.Finance.Api.dll</HintPath>
|
||||||
</Reference>
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Interfaces\Extensions\" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
@ -11,16 +11,20 @@ namespace Schaad.Accounting.Repositories
|
||||||
{
|
{
|
||||||
private readonly string ACCOUNTS = "Accounts.xml";
|
private readonly string ACCOUNTS = "Accounts.xml";
|
||||||
|
|
||||||
public AccountRepository(ISettingsService settingsService, RepositoryCache cache)
|
public AccountRepository(ISettingsService settingsService) : base(settingsService)
|
||||||
: base(settingsService, cache)
|
|
||||||
{
|
{
|
||||||
EnsureAccountsFile();
|
EnsureFileExisits(ACCOUNTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Load accounts
|
/// Load accounts
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<Account> GetAccountList() => LoadList<Account>(ACCOUNTS);
|
public List<Account> GetAccountList()
|
||||||
|
{
|
||||||
|
var accounts = Load<List<Account>>(ACCOUNTS);
|
||||||
|
return accounts ?? new List<Account>();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Save an account (insert/update)
|
/// Save an account (insert/update)
|
||||||
|
|
@ -75,40 +79,35 @@ namespace Schaad.Accounting.Repositories
|
||||||
SaveAccount(account);
|
SaveAccount(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureAccountsFile()
|
private new void EnsureFileExisits(string file)
|
||||||
{
|
{
|
||||||
string filePath = Path.Combine(settingsService.GetDbPath(), ACCOUNTS);
|
string filePath = Path.Combine(settingsService.GetDbPath(), file);
|
||||||
if (File.Exists(filePath))
|
if (File.Exists(filePath) == false)
|
||||||
{
|
{
|
||||||
return;
|
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), file);
|
||||||
}
|
if (File.Exists(lastYearFile))
|
||||||
|
File.Copy(lastYearFile, filePath);
|
||||||
|
|
||||||
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), ACCOUNTS);
|
// set start balance to last bank balance
|
||||||
if (File.Exists(lastYearFile))
|
if (file.IndexOf("Accounts") > -1)
|
||||||
{
|
|
||||||
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))
|
|
||||||
{
|
|
||||||
if (account.LastBankBalance > 0)
|
|
||||||
{
|
{
|
||||||
account.StartBalance = account.LastBankBalance;
|
var accounts = GetAccountList();
|
||||||
modified = true;
|
foreach (var account in accounts.Where(a => a.Class == ClassIds.Activa))
|
||||||
|
{
|
||||||
|
// bank accounts
|
||||||
|
if (account.LastBankBalance > 0)
|
||||||
|
{
|
||||||
|
account.StartBalance = account.LastBankBalance;
|
||||||
|
SaveAccount(account);
|
||||||
|
}
|
||||||
|
// cash accounts
|
||||||
|
else if (account.Number < 1010)
|
||||||
|
{
|
||||||
|
account.StartBalance = 0;
|
||||||
|
SaveAccount(account);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (account.Number < 1010)
|
|
||||||
{
|
|
||||||
account.StartBalance = 0;
|
|
||||||
modified = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (modified)
|
|
||||||
{
|
|
||||||
Save(accounts, ACCOUNTS);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,18 @@ namespace Schaad.Accounting.Repositories
|
||||||
{
|
{
|
||||||
private readonly string BANK_TRANSACTIONS = "BankTransactions.xml";
|
private readonly string BANK_TRANSACTIONS = "BankTransactions.xml";
|
||||||
|
|
||||||
public BankTransactionRepository(ISettingsService settingsService, RepositoryCache cache)
|
public BankTransactionRepository(ISettingsService settingsService) : base(settingsService)
|
||||||
: base(settingsService, cache)
|
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Load bank transactions
|
/// Load bank transactions
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<BankTransaction> GetBankTransactionList() => LoadList<BankTransaction>(BANK_TRANSACTIONS);
|
public List<BankTransaction> GetBankTransactionList()
|
||||||
|
{
|
||||||
|
var transactions = Load<List<BankTransaction>>(BANK_TRANSACTIONS);
|
||||||
|
return transactions != null ? transactions : new List<BankTransaction>();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Save new bank transactions, ignore existing ones (no update)
|
/// Save new bank transactions, ignore existing ones (no update)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
using System;
|
using System.IO;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Xml;
|
using System.Xml;
|
||||||
using System.Xml.Serialization;
|
using System.Xml.Serialization;
|
||||||
|
|
@ -11,111 +8,65 @@ namespace Schaad.Accounting.Repositories
|
||||||
{
|
{
|
||||||
public abstract class BaseRepository
|
public abstract class BaseRepository
|
||||||
{
|
{
|
||||||
// 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 readonly ISettingsService settingsService;
|
protected readonly ISettingsService settingsService;
|
||||||
protected readonly RepositoryCache cache;
|
|
||||||
|
|
||||||
protected BaseRepository(ISettingsService settingsService, RepositoryCache cache)
|
protected BaseRepository(ISettingsService settingsService)
|
||||||
{
|
{
|
||||||
this.settingsService = settingsService;
|
this.settingsService = settingsService;
|
||||||
this.cache = cache;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void EnsureFileExists(string fileName)
|
protected void EnsureFileExisits(string fileName)
|
||||||
{
|
{
|
||||||
string filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
string filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
||||||
lock (GetLock(filePath))
|
if (File.Exists(filePath) == false)
|
||||||
{
|
{
|
||||||
if (File.Exists(filePath) == false)
|
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), fileName);
|
||||||
|
if (File.Exists(lastYearFile))
|
||||||
{
|
{
|
||||||
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), fileName);
|
File.Copy(lastYearFile, filePath);
|
||||||
if (File.Exists(lastYearFile))
|
|
||||||
{
|
|
||||||
File.Copy(lastYearFile, filePath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Load a list, cached per scope. First call reads the XML file; subsequent calls in
|
/// Save an object to an xml file
|
||||||
/// 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>
|
/// </summary>
|
||||||
protected void Save<T>(T obj, string fileName)
|
protected void Save<T>(T obj, string fileName)
|
||||||
{
|
{
|
||||||
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
||||||
var tmpPath = filePath + ".tmp";
|
using (var sww = new MemoryStream())
|
||||||
|
|
||||||
lock (GetLock(filePath))
|
|
||||||
{
|
{
|
||||||
var settings = new XmlWriterSettings
|
var settings = new XmlWriterSettings
|
||||||
{
|
{
|
||||||
Encoding = Encoding.UTF8,
|
Encoding = Encoding.UTF8,
|
||||||
Indent = true
|
Indent = true
|
||||||
};
|
};
|
||||||
|
using (var writer = XmlWriter.Create(sww, settings))
|
||||||
try
|
|
||||||
{
|
{
|
||||||
using (var writer = XmlWriter.Create(tmpPath, settings))
|
var xsSubmit = new XmlSerializer(typeof(T));
|
||||||
{
|
xsSubmit.Serialize(writer, obj);
|
||||||
var serializer = new XmlSerializer(typeof(T));
|
var xml = Encoding.UTF8.GetString(sww.ToArray());
|
||||||
serializer.Serialize(writer, obj);
|
File.WriteAllText(filePath, xml);
|
||||||
}
|
|
||||||
|
|
||||||
File.Move(tmpPath, filePath, overwrite: true);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
if (File.Exists(tmpPath))
|
|
||||||
{
|
|
||||||
try { File.Delete(tmpPath); } catch { /* best effort */ }
|
|
||||||
}
|
|
||||||
throw;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cache.Invalidate(filePath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Load an object from an xml file (uncached; used for one-off reads such as file
|
/// Load an object from an xml file
|
||||||
/// existence bootstrapping). Prefer <see cref="LoadList{T}"/> for repository reads.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
protected T Load<T>(string fileName)
|
protected T Load<T>(string fileName)
|
||||||
{
|
{
|
||||||
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
|
||||||
|
if (File.Exists(filePath) == false)
|
||||||
lock (GetLock(filePath))
|
|
||||||
{
|
{
|
||||||
if (File.Exists(filePath) == false)
|
return default(T);
|
||||||
{
|
}
|
||||||
return default(T);
|
|
||||||
}
|
|
||||||
|
|
||||||
using (XmlReader reader = XmlReader.Create(filePath))
|
using (XmlReader reader = XmlReader.Create(filePath))
|
||||||
{
|
{
|
||||||
var serializer = new XmlSerializer(typeof(T));
|
var serializer = new XmlSerializer(typeof(T));
|
||||||
return (T)serializer.Deserialize(reader);
|
return (T)serializer.Deserialize(reader);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object GetLock(string filePath)
|
|
||||||
=> FileLocks.GetOrAdd(filePath, _ => new object());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -10,16 +10,19 @@ namespace Schaad.Accounting.Repositories
|
||||||
{
|
{
|
||||||
private readonly string BOOKING_RULES = "BookingRules.xml";
|
private readonly string BOOKING_RULES = "BookingRules.xml";
|
||||||
|
|
||||||
public BookingRuleRepository(ISettingsService settingsService, RepositoryCache cache)
|
public BookingRuleRepository(ISettingsService settingsService) : base(settingsService)
|
||||||
: base(settingsService, cache)
|
|
||||||
{
|
{
|
||||||
EnsureFileExists(BOOKING_RULES);
|
EnsureFileExisits(BOOKING_RULES);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Load booking rules
|
/// Load booking rules
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<BookingRule> GetBookingRuleList() => LoadList<BookingRule>(BOOKING_RULES);
|
public List<BookingRule> GetBookingRuleList()
|
||||||
|
{
|
||||||
|
var bookingTexts = Load<List<BookingRule>>(BOOKING_RULES);
|
||||||
|
return bookingTexts ?? new List<BookingRule>();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Save a booking rule (insert/update)
|
/// Save a booking rule (insert/update)
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,19 @@ namespace Schaad.Accounting.Repositories
|
||||||
{
|
{
|
||||||
private readonly string BOOKING_TEXTS = "BookingTexts.xml";
|
private readonly string BOOKING_TEXTS = "BookingTexts.xml";
|
||||||
|
|
||||||
public BookingTextRepository(ISettingsService settingsService, RepositoryCache cache)
|
public BookingTextRepository(ISettingsService settingsService) : base(settingsService)
|
||||||
: base(settingsService, cache)
|
|
||||||
{
|
{
|
||||||
EnsureFileExists(BOOKING_TEXTS);
|
EnsureFileExisits(BOOKING_TEXTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Load booking texts
|
/// Load bokking texts
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<BookingText> GetBookingTextList() => LoadList<BookingText>(BOOKING_TEXTS);
|
public List<BookingText> GetBookingTextList()
|
||||||
|
{
|
||||||
|
var bookingTexts = Load<List<BookingText>>(BOOKING_TEXTS);
|
||||||
|
return bookingTexts ?? new List<BookingText>();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Save a booking text (insert/update)
|
/// Save a booking text (insert/update)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
|
|
@ -6,23 +6,26 @@ using Schaad.Accounting.Models;
|
||||||
|
|
||||||
namespace Schaad.Accounting.Repositories
|
namespace Schaad.Accounting.Repositories
|
||||||
{
|
{
|
||||||
public class SplitPredefinitionRepository : BaseRepository, ISplitPredefinitionRepository
|
public class SplitPredefinitonRepository : BaseRepository, ISplitPredefinitonRepository
|
||||||
{
|
{
|
||||||
private readonly string SPLIT_PREDEFINITION = "SplitPredefinitions.xml";
|
private readonly string SPLIT_PREDEFINITION = "SplitPredefinitions.xml";
|
||||||
|
|
||||||
public SplitPredefinitionRepository(ISettingsService settingsService, RepositoryCache cache)
|
public SplitPredefinitonRepository(ISettingsService settingsService) : base(settingsService)
|
||||||
: base(settingsService, cache)
|
|
||||||
{
|
{
|
||||||
EnsureFileExists(SPLIT_PREDEFINITION);
|
EnsureFileExisits(SPLIT_PREDEFINITION);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Load split predefinitions
|
/// Load booking rules
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<SplitPredefinition> GetSplitPredefinitionList() => LoadList<SplitPredefinition>(SPLIT_PREDEFINITION);
|
public List<SplitPredefinition> GetSplitPredefinitionList()
|
||||||
|
{
|
||||||
|
var definitions = Load<List<SplitPredefinition>>(SPLIT_PREDEFINITION);
|
||||||
|
return definitions ?? new List<SplitPredefinition>();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Save a split predefinition (insert/update)
|
/// Save a booking rule (insert/update)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void SaveSplitPredefinition(SplitPredefinition splitPredefinition)
|
public void SaveSplitPredefinition(SplitPredefinition splitPredefinition)
|
||||||
{
|
{
|
||||||
|
|
@ -11,10 +11,9 @@ namespace Schaad.Accounting.Repositories
|
||||||
private readonly Dictionary<int, string> classes = new Dictionary<int, string>();
|
private readonly Dictionary<int, string> classes = new Dictionary<int, string>();
|
||||||
private readonly string SUBCLASSES = "SubClasses.xml";
|
private readonly string SUBCLASSES = "SubClasses.xml";
|
||||||
|
|
||||||
public SubclassRepository(ISettingsService settingsService, RepositoryCache cache)
|
public SubclassRepository(ISettingsService settingsService) : base(settingsService)
|
||||||
: base(settingsService, cache)
|
|
||||||
{
|
{
|
||||||
EnsureFileExists(SUBCLASSES);
|
EnsureFileExisits(SUBCLASSES);
|
||||||
|
|
||||||
classes.Add(ClassIds.Activa, "Aktiven");
|
classes.Add(ClassIds.Activa, "Aktiven");
|
||||||
classes.Add(ClassIds.Passiva, "Passiven");
|
classes.Add(ClassIds.Passiva, "Passiven");
|
||||||
|
|
@ -23,9 +22,13 @@ namespace Schaad.Accounting.Repositories
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Load subclasses
|
/// Load bokking texts
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<SubClass> GetSubClassList() => LoadList<SubClass>(SUBCLASSES);
|
public List<SubClass> GetSubClassList()
|
||||||
|
{
|
||||||
|
var subclasses = Load<List<SubClass>>(SUBCLASSES);
|
||||||
|
return subclasses ?? new List<SubClass>();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Save a booking text (insert/update)
|
/// Save a booking text (insert/update)
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,7 @@ namespace Schaad.Accounting.Repositories
|
||||||
private readonly IAccountRepository accountRepository;
|
private readonly IAccountRepository accountRepository;
|
||||||
private readonly string TRANSACTIONS = "Transactions.xml";
|
private readonly string TRANSACTIONS = "Transactions.xml";
|
||||||
|
|
||||||
public TransactionRepository(ISettingsService settingsService, RepositoryCache cache, IAccountRepository accountRepository)
|
public TransactionRepository(ISettingsService settingsService, IAccountRepository accountRepository) : base(settingsService)
|
||||||
: base(settingsService, cache)
|
|
||||||
{
|
{
|
||||||
this.accountRepository = accountRepository;
|
this.accountRepository = accountRepository;
|
||||||
}
|
}
|
||||||
|
|
@ -20,7 +19,11 @@ namespace Schaad.Accounting.Repositories
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get transaction list
|
/// Get transaction list
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<Transaction> GetTransactionList() => LoadList<Transaction>(TRANSACTIONS);
|
public List<Transaction> GetTransactionList()
|
||||||
|
{
|
||||||
|
var transactionList = Load<List<Transaction>>(TRANSACTIONS);
|
||||||
|
return transactionList ?? new List<Transaction>();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Save a transaction (insert/update)
|
/// Save a transaction (insert/update)
|
||||||
|
|
@ -64,24 +67,17 @@ namespace Schaad.Accounting.Repositories
|
||||||
public Transaction GetTransaction(string id)
|
public Transaction GetTransaction(string id)
|
||||||
{
|
{
|
||||||
var transactions = GetTransactionList();
|
var transactions = GetTransactionList();
|
||||||
var stored = transactions.FirstOrDefault(t => t.Id == id);
|
var transaction = transactions.FirstOrDefault(t => t.Id == id);
|
||||||
if (stored == null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = new Transaction();
|
|
||||||
stored.Copy(result);
|
|
||||||
|
|
||||||
// value is stored in CHF -> convert back to foreign currency for display/editing
|
// value is stored in CHF -> convert back to foreign currency for display/editing
|
||||||
var isFxAccount = accountRepository.GetAccount(result.OriginAccountId).IsFxAccount
|
var isFxAccount = accountRepository.GetAccount(transaction.OriginAccountId).IsFxAccount
|
||||||
|| accountRepository.GetAccount(result.TargetAccountId).IsFxAccount;
|
|| accountRepository.GetAccount(transaction.TargetAccountId).IsFxAccount;
|
||||||
if (isFxAccount && result.FxRate != 0)
|
if (isFxAccount && transaction.FxRate != 0)
|
||||||
{
|
{
|
||||||
result.Value = result.Value / result.FxRate.Value;
|
transaction.Value = transaction.Value / transaction.FxRate.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return transaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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,6 +4,9 @@
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />
|
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Schaad.Accounting.Datasets;
|
|
||||||
using Schaad.Accounting.Datasets.Charts;
|
using Schaad.Accounting.Datasets.Charts;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
|
|
||||||
|
|
@ -9,88 +8,115 @@ namespace Schaad.Accounting.Services
|
||||||
{
|
{
|
||||||
public class ChartService : IChartService
|
public class ChartService : IChartService
|
||||||
{
|
{
|
||||||
|
private readonly IAccountRepository accountRepository;
|
||||||
private readonly ISettingsService settingsService;
|
private readonly ISettingsService settingsService;
|
||||||
|
private readonly ISubclassRepository subclassRepository;
|
||||||
private readonly IViewService viewService;
|
private readonly IViewService viewService;
|
||||||
|
|
||||||
public ChartService(ISettingsService settingsService, IViewService viewService)
|
public ChartService(
|
||||||
|
ISettingsService settingsService,
|
||||||
|
IViewService viewService,
|
||||||
|
IAccountRepository accountRepository,
|
||||||
|
ISubclassRepository subclassRepository)
|
||||||
{
|
{
|
||||||
this.settingsService = settingsService;
|
this.settingsService = settingsService;
|
||||||
this.viewService = viewService;
|
this.viewService = viewService;
|
||||||
|
this.accountRepository = accountRepository;
|
||||||
|
this.subclassRepository = subclassRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<DataSerie> GetExpensesPerMonth()
|
public IReadOnlyList<DataSerie> GetExpensesPerMonth()
|
||||||
{
|
{
|
||||||
var expenseTransactions = viewService.GetTransactionViewList()
|
var transactions = viewService.GetTransactionViewList().Where(a => a.TargetAccount.Class == ClassIds.Expenses);
|
||||||
.Where(t => t.TargetAccount.Class == ClassIds.Expenses)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var distinctSubClasses = expenseTransactions
|
// More than one subclass xx
|
||||||
.Select(t => t.TargetAccount.SubClass)
|
if (transactions.Select(t => t.TargetAccount.SubClass).Distinct().Count() > 1)
|
||||||
.Distinct()
|
{
|
||||||
.Count();
|
return GetSubClassExpensesPerMonth();
|
||||||
|
}
|
||||||
|
|
||||||
return distinctSubClasses > 1
|
// only one subclass xx (z.B. Mandant Mannenbach)
|
||||||
? GetSubClassExpensesPerMonth(expenseTransactions)
|
return GetAccountExpensesPerMonth();
|
||||||
: GetAccountExpensesPerMonth();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<DataSerie> GetSubClassExpensesPerMonth(List<TransactionDataset> expenseTransactions)
|
private List<DataSerie> GetSubClassExpensesPerMonth()
|
||||||
{
|
{
|
||||||
var year = settingsService.GetYear();
|
//var subclasses = subclassRepository.GetSubClassList();
|
||||||
var maxMonth = expenseTransactions
|
var transactions = viewService.GetTransactionViewList().Where(a => a.TargetAccount.Class == ClassIds.Expenses).ToList();
|
||||||
.OrderByDescending(t => t.ValueDate)
|
|
||||||
.FirstOrDefault()?.ValueDate.Month ?? 12;
|
|
||||||
|
|
||||||
var list = new List<DataSerie>();
|
var list = new List<DataSerie>();
|
||||||
foreach (var grp in expenseTransactions.GroupBy(t => t.TargetAccount.SubClass))
|
|
||||||
|
var newestTransaction = transactions.OrderByDescending(t => t.ValueDate).FirstOrDefault();
|
||||||
|
var maxMonth = newestTransaction?.ValueDate.Month ?? 12;
|
||||||
|
var year = settingsService.GetYear();
|
||||||
|
|
||||||
|
// Group by subclass
|
||||||
|
foreach (var grp in transactions.GroupBy(a => a.TargetAccount.SubClass).Select(a => new {Key = a.Key, List = a.ToList()}))
|
||||||
{
|
{
|
||||||
var groupedByMonth = grp
|
// Sum subclass transactions per month
|
||||||
.GroupBy(t => t.ValueDate.Month)
|
var groupedByMonth = grp.List.GroupBy(g => g.ValueDate.Month).ToDictionary(g => g.Key, g => g.ToList().Sum(s => s.Value));
|
||||||
.ToDictionary(g => g.Key, g => g.Sum(t => t.Value));
|
|
||||||
EnsureEntryForEveryMonth(groupedByMonth, maxMonth);
|
EnsureEntryForEveryMonth(groupedByMonth, maxMonth);
|
||||||
|
|
||||||
list.Add(new DataSerie(
|
//var subClass = subclasses.FirstOrDefault(s => s.Number == grp.List.First().TargetAccount.SubClass);
|
||||||
Id: grp.Key.ToString(),
|
list.Add(
|
||||||
Name: grp.First().TargetAccount.Name,
|
new DataSerie(
|
||||||
X: groupedByMonth.OrderBy(g => g.Key).Select(g => new DateOnly(year, g.Key, 1)).ToList(),
|
Id: grp.List.First().TargetAccount.SubClass.ToString(),
|
||||||
Y: groupedByMonth.OrderBy(g => g.Key).Select(g => g.Value).ToList()
|
Name: grp.List.First().TargetAccount.Name,
|
||||||
));
|
X: groupedByMonth.OrderBy(g => g.Key).Select(g => new DateOnly(year, g.Key, 1)).ToList(),
|
||||||
|
Y: groupedByMonth.OrderBy(g => g.Key).Select(g => g.Value).ToList()
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<DataSerie> GetAccountExpensesPerMonth()
|
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 list = new List<DataSerie>();
|
||||||
foreach (var account in expenseAccounts)
|
var expensesAccounts = viewService.GetAccountViewList().Where(a => a.Class == ClassIds.Expenses);
|
||||||
|
foreach (var account in expensesAccounts)
|
||||||
{
|
{
|
||||||
var transactions = allTransactions.Where(t => t.TargetAccountId == account.Id).ToList();
|
var serie = GetAccountExpensesPerMonth(account.Id, DateTime.Now.Year);
|
||||||
if (transactions.Count == 0)
|
if (serie != null)
|
||||||
{
|
{
|
||||||
continue;
|
list.Add(serie);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void EnsureEntryForEveryMonth(Dictionary<int, decimal> values, int maxMonth = 12)
|
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)
|
||||||
{
|
{
|
||||||
for (int i = 1; i <= maxMonth; i++)
|
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)
|
public decimal ConvertCurrency(decimal amount, string fromCurrency, string toCurrency, string fixerIoApiKey)
|
||||||
{
|
{
|
||||||
if (currencies.Contains(fromCurrency) && currencies.Contains(toCurrency))
|
if (currencies.Contains(fromCurrency) && currencies.Contains(fromCurrency))
|
||||||
{
|
{
|
||||||
return amount;
|
return amount;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
@ -129,22 +130,28 @@ namespace Schaad.Accounting.Services
|
||||||
{
|
{
|
||||||
var credit = "";
|
var credit = "";
|
||||||
var debit = "";
|
var debit = "";
|
||||||
var signedValue = trx.Value;
|
|
||||||
|
|
||||||
if (trx.OriginAccountId == accountId)
|
if (trx.OriginAccountId == accountId)
|
||||||
{
|
{
|
||||||
debit = trx.Value.ToFormattedString();
|
debit = ToFormattedString(trx.Value);
|
||||||
signedValue = -trx.Value;
|
trx.Value *= -1;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
credit = trx.Value.ToFormattedString();
|
credit = ToFormattedString(trx.Value);
|
||||||
}
|
}
|
||||||
balance += signedValue;
|
balance += trx.Value;
|
||||||
sb.AppendLine($"{trx.BookingDate:dd.MM.yyyy};{trx.ValueDate:dd.MM.yyyy};{trx.Text};{debit};{credit};{balance.ToFormattedString()}");
|
sb.AppendLine($"{trx.BookingDate:dd.MM.yyyy};{trx.ValueDate:dd.MM.yyyy};{trx.Text};{debit};{credit};{ToFormattedString(balance)}");
|
||||||
}
|
}
|
||||||
var fileBytes = Encoding.GetEncoding("ISO-8859-1").GetBytes(sb.ToString());
|
var fileBytes = Encoding.GetEncoding("ISO-8859-1").GetBytes(sb.ToString());
|
||||||
return fileBytes;
|
return fileBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string ToFormattedString(decimal value)
|
||||||
|
{
|
||||||
|
var culture = new CultureInfo("de-CH");
|
||||||
|
culture.NumberFormat.NumberGroupSeparator = "'";
|
||||||
|
return value.ToString("#,0.00", culture);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FreeSpire.PDF" Version="10.2.0" />
|
<PackageReference Include="FreeSpire.PDF" Version="10.2.0" />
|
||||||
|
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />
|
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ namespace Schaad.Accounting.Services
|
||||||
|
|
||||||
public bool TrySetYear(int year)
|
public bool TrySetYear(int year)
|
||||||
{
|
{
|
||||||
var oldYear = this.year;
|
var oldYear = year;
|
||||||
SetYear(year);
|
SetYear(year);
|
||||||
if (Directory.GetFiles(GetDbPath()).Any() == false)
|
if (Directory.GetFiles(GetDbPath()).Any() == false)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -57,43 +57,22 @@ namespace Schaad.Accounting.Services
|
||||||
var subClassNameByNumber = subclassRepository.GetSubClassList().ToDictionary(s => s.Number, s => s.Name);
|
var subClassNameByNumber = subclassRepository.GetSubClassList().ToDictionary(s => s.Number, s => s.Name);
|
||||||
var settings = settingsService.GetSettings();
|
var settings = settingsService.GetSettings();
|
||||||
|
|
||||||
// Group transactions by account so per-account balance is O(k) instead of O(M).
|
var accountList = accounts.Select(
|
||||||
var creditsByAccount = transactionList.GroupBy(t => t.TargetAccountId).ToDictionary(g => g.Key, g => g.ToList());
|
a =>
|
||||||
var debitsByAccount = transactionList.GroupBy(t => t.OriginAccountId).ToDictionary(g => g.Key, g => g.ToList());
|
new AccountDataset(
|
||||||
|
account: a,
|
||||||
var accountList = accounts.Select(a =>
|
balance: GetBalanceInAccountCurrency(a, transactionList),
|
||||||
{
|
balanceCHF: GetCurrentBalanceInCHF(a, transactionList),
|
||||||
var balance = ComputeBalance(a, creditsByAccount, debitsByAccount);
|
startBalanceCHF: fxService.ConvertCurrency(a.StartBalance, a.Currency, "CHF", settings.FixerIoApiKey),
|
||||||
return new AccountDataset(
|
className: subclassRepository.GetClass(a.Class),
|
||||||
account: a,
|
subClassName: subClassNameByNumber[a.SubClass]
|
||||||
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]);
|
|
||||||
})
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return accountList.OrderBy(a => a.Number).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()
|
public BalanceDataset GetBalanceView()
|
||||||
{
|
{
|
||||||
var accountList = GetAccountViewList();
|
var accountList = GetAccountViewList();
|
||||||
|
|
@ -128,20 +107,40 @@ namespace Schaad.Accounting.Services
|
||||||
return balanceView;
|
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>
|
/// <summary>
|
||||||
/// Get transaction list with the origin and target account for each transaction
|
/// Get transaction list with the origin and target account for each transaction
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<TransactionDataset> GetTransactionViewList()
|
public List<TransactionDataset> GetTransactionViewList()
|
||||||
{
|
{
|
||||||
var accountsById = accountRepository.GetAccountList().ToDictionary(a => a.Id);
|
var accountList = accountRepository.GetAccountList();
|
||||||
var transactionList = transactionRepository.GetTransactionList();
|
var transactionList = transactionRepository.GetTransactionList();
|
||||||
|
|
||||||
return transactionList.Select(t =>
|
var transactionViewList = transactionList.Select(
|
||||||
new TransactionDataset(
|
t =>
|
||||||
t,
|
new TransactionDataset(
|
||||||
accountsById[t.OriginAccountId],
|
t,
|
||||||
accountsById[t.TargetAccountId]))
|
accountList.Single(a => a.Id == t.OriginAccountId),
|
||||||
|
accountList.Single(a => a.Id == t.TargetAccountId))
|
||||||
|
)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
return transactionViewList;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -149,41 +148,50 @@ namespace Schaad.Accounting.Services
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<TransactionDataset> GetTransactionViewList(string accountId)
|
public List<TransactionDataset> GetTransactionViewList(string accountId)
|
||||||
{
|
{
|
||||||
var accountsById = accountRepository.GetAccountList().ToDictionary(a => a.Id);
|
var accountList = accountRepository.GetAccountList();
|
||||||
var transactionList = transactionRepository.GetTransactionList()
|
var transactionList = transactionRepository.GetTransactionList().Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId);
|
||||||
.Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId);
|
|
||||||
|
|
||||||
return transactionList.Select(t =>
|
var transactionViewList = transactionList.Select(
|
||||||
new TransactionDataset(
|
t =>
|
||||||
WithDisplaySign(t),
|
new TransactionDataset(
|
||||||
accountsById[t.OriginAccountId],
|
Prepare(t),
|
||||||
accountsById[t.TargetAccountId]))
|
accountList.Single(a => a.Id == t.OriginAccountId),
|
||||||
|
accountList.Single(a => a.Id == t.TargetAccountId)
|
||||||
|
)
|
||||||
|
)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
Transaction WithDisplaySign(Transaction t)
|
return transactionViewList;
|
||||||
|
|
||||||
|
Transaction Prepare(Transaction t)
|
||||||
{
|
{
|
||||||
var originAccount = accountsById[t.OriginAccountId];
|
var account = accountList.Single(a => a.Id == t.OriginAccountId);
|
||||||
if (originAccount.Class == ClassIds.Activa && accountId == t.OriginAccountId)
|
|
||||||
|
if (account.Class == ClassIds.Activa && accountId == t.OriginAccountId )
|
||||||
{
|
{
|
||||||
var copy = new Transaction();
|
t.Value *= -1;
|
||||||
t.Copy(copy);
|
|
||||||
copy.Value *= -1;
|
|
||||||
return copy;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get booking rules with their account
|
/// Get booking rules with their account
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<BookingRuleDataset> GetBookingRuleViewList()
|
public List<BookingRuleDataset> GetBookingRuleViewList()
|
||||||
{
|
{
|
||||||
var accountsById = accountRepository.GetAccountList().ToDictionary(a => a.Id);
|
var accountList = accountRepository.GetAccountList();
|
||||||
var bookingRules = bookingRuleRepository.GetBookingRuleList();
|
var bookinRuleList = bookingRuleRepository.GetBookingRuleList();
|
||||||
|
|
||||||
return bookingRules.Select(t =>
|
return bookinRuleList.Select(
|
||||||
new BookingRuleDataset(t, accountsById[t.AccountId].Name))
|
t =>
|
||||||
|
new BookingRuleDataset(
|
||||||
|
t,
|
||||||
|
accountList.Single(a => a.Id == t.AccountId).Name
|
||||||
|
)
|
||||||
|
)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
using System.Globalization;
|
|
||||||
using Shouldly;
|
|
||||||
|
|
||||||
namespace Schaad.Accounting.Tests;
|
|
||||||
|
|
||||||
public class FormattingTestShould
|
|
||||||
{
|
|
||||||
[Theory]
|
|
||||||
[InlineData(0, "0.00")]
|
|
||||||
[InlineData(1.5, "1.50")]
|
|
||||||
[InlineData(-1.5, "-1.50")]
|
|
||||||
[InlineData(1234.5, "1'234.50")]
|
|
||||||
[InlineData(1234567.89, "1'234'567.89")]
|
|
||||||
[InlineData(-1234567.89, "-1'234'567.89")]
|
|
||||||
public void FormatDecimalWithSwissThousandsSeparatorWhenCalled(decimal value, string expected)
|
|
||||||
{
|
|
||||||
value.ToFormattedString().ShouldBe(expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RoundToTwoDecimalsWhenValueHasMorePrecision()
|
|
||||||
{
|
|
||||||
1.234m.ToFormattedString().ShouldBe("1.23");
|
|
||||||
1.235m.ToFormattedString().ShouldBe("1.24");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void IgnoreAmbientCultureWhenFormatting()
|
|
||||||
{
|
|
||||||
var previous = CultureInfo.CurrentCulture;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
|
|
||||||
1234.5m.ToFormattedString().ShouldBe("1'234.50");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
CultureInfo.CurrentCulture = previous;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
|
||||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
|
||||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Using Include="Xunit" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />
|
|
||||||
<ProjectReference Include="..\Schaad.Accounting.Db\Schaad.Accounting.Db.csproj" />
|
|
||||||
<ProjectReference Include="..\Schaad.Accounting.Services\Schaad.Accounting.Services.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Reference Include="Schaad.Finance.Api">
|
|
||||||
<HintPath>..\dlls\Schaad.Finance.Api.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
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,5 +1,4 @@
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Schaad.Accounting;
|
|
||||||
using Schaad.Accounting.Datasets;
|
using Schaad.Accounting.Datasets;
|
||||||
using Schaad.Accounting.Interfaces;
|
using Schaad.Accounting.Interfaces;
|
||||||
|
|
||||||
|
|
@ -23,8 +22,8 @@ public partial class ProfitLossReport : ComponentBase
|
||||||
protected override Task OnInitializedAsync()
|
protected override Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
accounts = viewService.GetAccountViewList();
|
accounts = viewService.GetAccountViewList();
|
||||||
profit = Math.Abs(accounts.Where(m => m.Class == ClassIds.Income).Sum(m => m.Balance));
|
profit = Math.Abs(accounts.Where(m => m.Class == 3).Sum(m => m.Balance));
|
||||||
loss = Math.Abs(accounts.Where(m => m.Class == ClassIds.Expenses).Sum(m => m.Balance));
|
loss = Math.Abs(accounts.Where(m => m.Class == 4).Sum(m => m.Balance));
|
||||||
win = profit-loss;
|
win = profit-loss;
|
||||||
|
|
||||||
(header, footer) = Report.GetViewDataTitleAndFooter("Erfolgsrechnung", settingsService);
|
(header, footer) = Report.GetViewDataTitleAndFooter("Erfolgsrechnung", settingsService);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Schaad.Accounting.Interfaces;
|
using System.Globalization;
|
||||||
|
using Schaad.Accounting.Interfaces;
|
||||||
using Schaad.Accounting.Repositories;
|
using Schaad.Accounting.Repositories;
|
||||||
using Schaad.Accounting.Services;
|
using Schaad.Accounting.Services;
|
||||||
using Schaad.Finance.Api;
|
using Schaad.Finance.Api;
|
||||||
|
|
@ -8,20 +9,21 @@ namespace Schaad.Accounting.UI
|
||||||
{
|
{
|
||||||
public static class Extensions
|
public static class Extensions
|
||||||
{
|
{
|
||||||
public static IServiceCollection AddAccounting(this IServiceCollection services)
|
public static string ToFormattedString(this decimal value)
|
||||||
{
|
{
|
||||||
// Singleton because MyHeader triggers a full page reload (forceLoad: true) after
|
var culture = new CultureInfo("de-CH");
|
||||||
// changing year/mandator; a Scoped instance would be recreated on the new circuit
|
culture.NumberFormat.NumberGroupSeparator = "'";
|
||||||
// and lose the selection. TODO: persist selection to a cookie/query string so this
|
return value.ToString("#,0.00", culture);
|
||||||
// can safely become Scoped (see IMPROVEMENT_PLAN.md).
|
}
|
||||||
services.AddSingleton<ISettingsService, SettingsService>();
|
|
||||||
|
|
||||||
services.AddScoped<RepositoryCache>();
|
public static IServiceCollection AddRepositories(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<ISettingsService, SettingsService>();
|
||||||
services.AddScoped<IAccountRepository, AccountRepository>();
|
services.AddScoped<IAccountRepository, AccountRepository>();
|
||||||
services.AddScoped<IBankTransactionRepository, BankTransactionRepository>();
|
services.AddScoped<IBankTransactionRepository, BankTransactionRepository>();
|
||||||
services.AddScoped<IBookingRuleRepository, BookingRuleRepository>();
|
services.AddScoped<IBookingRuleRepository, BookingRuleRepository>();
|
||||||
services.AddScoped<IBookingTextRepository, BookingTextRepository>();
|
services.AddScoped<IBookingTextRepository, BookingTextRepository>();
|
||||||
services.AddScoped<ISplitPredefinitionRepository, SplitPredefinitionRepository>();
|
services.AddScoped<ISplitPredefinitonRepository, SplitPredefinitonRepository>();
|
||||||
services.AddScoped<ISubclassRepository, SubclassRepository>();
|
services.AddScoped<ISubclassRepository, SubclassRepository>();
|
||||||
services.AddScoped<ITransactionRepository, TransactionRepository>();
|
services.AddScoped<ITransactionRepository, TransactionRepository>();
|
||||||
|
|
||||||
|
|
@ -35,5 +37,18 @@ namespace Schaad.Accounting.UI
|
||||||
|
|
||||||
return services;
|
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,5 +1,4 @@
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Microsoft.AspNetCore.Localization;
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.FluentUI.AspNetCore.Components;
|
using Microsoft.FluentUI.AspNetCore.Components;
|
||||||
using Schaad.Accounting.Datasets;
|
using Schaad.Accounting.Datasets;
|
||||||
|
|
@ -24,7 +23,7 @@ namespace Schaad.Accounting.UI
|
||||||
builder.Services.AddSingleton(sp =>
|
builder.Services.AddSingleton(sp =>
|
||||||
sp.GetRequiredService<IOptions<SettingsDataset>>().Value);
|
sp.GetRequiredService<IOptions<SettingsDataset>>().Value);
|
||||||
|
|
||||||
builder.Services.AddAccounting();
|
builder.Services.AddRepositories().AddServices();
|
||||||
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
@ -39,19 +38,15 @@ namespace Schaad.Accounting.UI
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
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.UseAntiforgery();
|
||||||
app.MapStaticAssets();
|
app.MapStaticAssets();
|
||||||
app.MapRazorComponents<App>()
|
app.MapRazorComponents<App>()
|
||||||
.AddInteractiveServerRenderMode();
|
.AddInteractiveServerRenderMode();
|
||||||
|
|
||||||
|
var culture = new CultureInfo("de-CH");
|
||||||
|
CultureInfo.DefaultThreadCurrentCulture = culture;
|
||||||
|
CultureInfo.DefaultThreadCurrentUICulture = culture;
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue