Compare commits

...

7 commits

Author SHA1 Message Date
8926fb1e97 PR G: test project with xxxTestShould / DoThisWhenThat / Shouldly
Add Schaad.Accounting.Tests (xUnit + NSubstitute + Shouldly, net9.0)
wired into Accounting.sln with a direct Reference to
Schaad.Finance.Api.dll so it flows into the test binary.

Naming convention: file/class is <Subject>TestShould, each test method
is DoThisWhenThat. Reads as a sentence:
"ViewService test should sum activa and passiva totals separately when
getting balance view". All assertions use Shouldly (.ShouldBe,
.ShouldBeNull, .ShouldContain, ...) rather than xUnit Assert.*.

38 tests across seven files:

- ViewServiceTestShould: balance math (activa/passiva totals, per-
  account balance from start balance + credits - debits, FX conversion
  to CHF) and bank-transaction auto-matching (booking-rule text,
  value-matching preference, same-accounts-last-month fallback,
  open-transaction filter).
- TransactionRepositoryTestShould: FX round-trip against a temp XML
  directory, unknown-id -> null, and the mutation-on-read regression
  from PR A (a second Get on the same FX transaction used to divide
  by FxRate again).
- FormattingTestShould: Swiss thousands separator, two-decimal
  rounding, culture independence.
- RepositoryCacheTestShould: loader called once, per-key isolation,
  invalidation forces reload, case-insensitive keys.
- AccountRepositoryTestShould: id assignment, in-place update,
  currency defaulting, delete, bank-account lookup, bank-balance
  update, and constructor re-run against an existing file.
- ChartServiceTestShould: honours settingsService.GetYear() without
  mutating it (locks in PR E), account vs sub-class grouping
  heuristic, skips empty accounts.
- FileServiceTestShould: CSV header + running balance for debit and
  credit lines, ordering by BookingDate/ValueDate/Value, no in-place
  value mutation (locks in PR A).

CLAUDE.md gains a `dotnet test` line. IMPROVEMENT_PLAN.md notes that
this landed as three PRs (G, H, I) and was squashed on request.

Also add `*.DotSettings.user` to .gitignore so Rider's per-user
solution settings don't get accidentally staged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 21:25:48 +02:00
5d75bbb64d PR F: precompute dictionaries in ViewService
Eliminate O(N*M) linear scans in view assembly.
GetTransactionViewList / GetBookingRuleViewList: precompute
accountsById and use dictionary lookups instead of accountList.Single
per row. GetAccountViewList: group transactions by
TargetAccountId/OriginAccountId once; per-account balance is now O(k)
instead of a full scan. Fold GetBalanceInAccountCurrency and
GetCurrentBalanceInCHF into a single ComputeBalance helper so the
account-currency balance is computed once per account instead of
twice.

Defer item 19 (async I/O): after PR D each XML file is loaded at most
once per circuit, and this app is single-user local Blazor Server, so
async would be ~40 files of churn for negligible benefit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 21:00:33 +02:00
2eedd2fd20 PR E: ChartService cleanup
Remove the settings-mutating year-hopping in GetAccountExpensesPerMonth
that discarded the user's header year selection and silently forced
the Spendings-over-time chart back to DateTime.Now.Year. The chart
now honours settingsService.GetYear(), skips accounts with no
transactions instead of returning null, and no longer depends on
IAccountRepository or ISubclassRepository.

Also reorder the plan: async becomes PR F, tests PR G.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 20:56:20 +02:00
1c7ba7f416 PR D: per-scope repository cache
Add a Scoped RepositoryCache that memoizes deserialized XML lists,
keyed by absolute file path. Each XML file is loaded at most once per
SignalR circuit; Save invalidates the corresponding entry so
subsequent reads reload from disk.

BaseRepository gains a LoadList<T>(fileName) helper; each of the seven
repositories now defines Get*List() as a one-liner over that helper
and takes RepositoryCache via the constructor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 20:52:24 +02:00
8961bb234c PR C: mechanical cleanup (typos, packages, formatting, culture)
- Consolidate decimal.ToFormattedString into Schaad.Accounting.Formatting
- Rename EnsureFileExisits -> EnsureFileExists and
  SplitPredefiniton* -> SplitPredefinition* (class, interface, files, DI reg)
- AccountRepository: single-save year-rollover start-balance seed
- Drop legacy .NET Standard packages redundant on net9.0 and the stale
  Interfaces\Extensions\ folder entry
- Set culture via RequestLocalizationOptions middleware
- Plan: item 9 (unit of work) split off into a dedicated PR D

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 20:46:55 +02:00
152d4c3d57 PR B: atomic XML writes and per-file locking
Save<T> writes to a .tmp sibling and atomically renames, so a crash
mid-write leaves the previous file intact. Load<T>, Save<T>, and
EnsureFileExisits share a per-absolute-path lock so concurrent
Save+Save and Save+Load can't observe a half-written file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 20:40:55 +02:00
9f11f6490b PR A: correctness fixes and improvement plan
- Add IMPROVEMENT_PLAN.md with phased plan for follow-up work
- Consolidate DI registrations into a single AddAccounting() extension;
  drop the duplicate service registrations and the PdfParsingService
  self-registration
- TrySetYear: capture this.year before overwriting so rollback actually
  restores the previous value
- DummyFxService: check toCurrency (was checking fromCurrency twice)
- TransactionRepository.GetTransaction: return a copy instead of mutating
  the loaded entity, and guard against unknown ids
- ViewService.GetTransactionViewList(accountId): flip the sign on a copy
  rather than mutating the entity returned by the repository
- FileService.GetTransactionListCsv: same treatment; use a local
  signedValue instead of mutating trx.Value
- ProfitLossReport: use ClassIds.Income/Expenses instead of magic 3/4
- CLAUDE.md: correct the ClassIds documentation (1/2/3/4, not
  1000/2000/3000/4000)

SettingsService lifetime is intentionally left as Singleton for now;
making it Scoped requires persisting year/mandator selection across
page reloads first (tracked in the plan).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 20:37:03 +02:00
34 changed files with 1371 additions and 318 deletions

3
.gitignore vendored
View file

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

View file

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

View file

@ -11,11 +11,14 @@ 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
``` ```
There are no automated tests or lint commands. Tests live in `Schaad.Accounting.Tests` (xUnit + NSubstitute + Shouldly). There is no lint command.
## Architecture ## Architecture
@ -61,4 +64,29 @@ 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=1000, Passiva=2000, Income=3000, Expenses=4000. `ClassIds` in `Schaad.Accounting.Common` defines the Swiss accounting chart-of-accounts classes: Activa=1, Passiva=2, Income=3, Expenses=4. These are the leading digit of an account number (accounts are 4-digit; `Account.Class = Number / 1000`).
## Testing conventions
Tests live in `Schaad.Accounting.Tests` and use **xUnit** + **NSubstitute** (mocks) + **Shouldly** (assertions).
- **File and class name**: `<Subject>TestShould` — one file per subject under test. Examples: `ViewServiceTestShould.cs`, `TransactionRepositoryTestShould.cs`, `FormattingTestShould.cs`.
- **Test method name**: `DoThisWhenThat` — describes the behavior first, then the condition. Read together the class + method form a sentence:
- `ViewServiceTestShould.SumActivaAndPassivaTotalsSeparatelyWhenGettingBalanceView`
- `TransactionRepositoryTestShould.ReturnNullWhenGettingUnknownTransactionId`
- **Assertions**: use Shouldly (`value.ShouldBe(expected)`, `list.ShouldBeEmpty()`, `x.ShouldBeNull()`, `list.ShouldContain(...)`, `first.ShouldBeSameAs(second)`, etc.). Do **not** use xUnit `Assert.*`.
Example:
```csharp
public class FormattingTestShould
{
[Fact]
public void RoundToTwoDecimalsWhenValueHasMorePrecision()
{
1.234m.ToFormattedString().ShouldBe("1.23");
}
}
```
Repository tests that hit real XML I/O use a temp directory via `Path.GetTempPath()` and clean up in `IDisposable.Dispose`. Pure service tests use NSubstitute mocks for every dependency and don't touch the filesystem.

105
IMPROVEMENT_PLAN.md Normal file
View file

@ -0,0 +1,105 @@
# AccountingNext — Technical Improvement Plan
Analysis and phased plan produced 2026-07-02. See conversation history for full context.
## Findings
### 1. Correctness bugs (must fix)
- **Shared mutable state across users.** `SettingsService` is registered as `Singleton` (`Schaad.Accounting.UI/Extensions.cs:21`) but holds mutable `year`/`mandator` fields (`Schaad.Accounting.Services/SettingsService.cs:13-14`). Any user switching year/mandator changes it for *all* connected Blazor Server users.
- **`TrySetYear` rollback is broken.** `SettingsService.cs:33` captures `var oldYear = year;` — that's the *parameter*, not `this.year`. The "rollback" restores the same value that just failed.
- **`ChartService` mutates a shared service.** `GetAccountExpensesPerMonth(accountId, year)` (`ChartService.cs:87`) calls `settingsService.TrySetYear(year)` then `SetYear(DateTime.Now.Year)` to reload prior-year data. Global mutation on a singleton — guaranteed to race in production.
- **`DummyFxService` has a typo.** `DummyFxService.cs:13` — `currencies.Contains(fromCurrency) && currencies.Contains(fromCurrency)` (both sides check `fromCurrency`). Also it silently returns the raw amount for supported currencies, so all "CHF conversions" are no-ops.
- **`TransactionRepository.GetTransaction` mutates the entity.** `TransactionRepository.cs:67-81` divides `Value` by `FxRate` on read, and `SaveTransaction` multiplies by `FxRate` on write. Also silently NREs when the id is not found (line 73 dereferences `transaction`).
- **`ViewService.GetTransactionViewList(accountId)` mutates transactions.** `ViewService.cs:172` flips `t.Value *= -1` on the loaded transaction. `FileService.GetTransactionListCsv` does the same (`FileService.cs:137`). These are the shared objects returned by the repository — later reads see wrong signs.
- **DI double-registration.** `Extensions.cs` registers `IChartService`, `IViewService`, `IFileService`, statement services, and `IFxService` in both `AddRepositories` and `AddServices`. Also `AddServices` registers `PdfParsingService` as its own key (`services.AddSingleton<PdfParsingService, PdfParsingService>()`) instead of `IPdfParsingService`.
- **Magic numbers instead of `ClassIds`.** `ProfitLossReport.razor.cs:25-26` uses `.Class == 3` / `.Class == 4`.
- **`ClassIds` doc/code mismatch.** `CLAUDE.md` states `Activa=1000, Passiva=2000, …`, but `ClassIds.cs` defines them as `1,2,3,4` (matches `Account.Class = Number / 1000`).
- **Non-atomic XML writes, no locking.** `BaseRepository.Save` writes directly to the target path. Interrupted writes corrupt data. Two concurrent saves interleave. No temp-file+rename, no `FileShare` lock.
- **`AccountRepository.EnsureFileExisits` calls `SaveAccount` in a loop.** Each call re-loads and re-serializes the whole account list.
- **`ProfitLossReport` / `BalanceSheetReport`** compute totals by re-summing balances that `ViewService` already computes — but with subtle differences. Two sources of truth for the same number.
### 2. Reliability / maintainability
- **No async I/O.** Every XML read/write is synchronous, blocking the SignalR hub thread in Blazor Server.
- **No caching.** A single page render calls `GetAccountList()` and `GetTransactionList()` many times. Every one is a fresh file read + XML deserialization.
- **O(N·M) lookups.** `ViewService.cs:138-139` — `accountList.Single(...)` inside a `Select` over all transactions. Should be a dictionary keyed by id.
- **No logging.** `ILogger` is not used anywhere.
- **No error surface.** Repositories throw or return `null`; UI dereferences with `!`.
- **Anemic domain model + `Copy` methods.** `Account.Copy`, `Transaction.Copy`, `SubClass.Copy` etc. are hand-rolled property copies used to merge edits into loaded entities.
- **Vendor coupling in the Domain project.** `Schaad.Accounting.Common` references `Schaad.Finance.Api.dll`. `IChartService`, `IFileService`, and `IViewService` live in `Common` and depend on `Schaad.Finance.Api` types.
- **Duplicated formatting logic.** `ToFormattedString(decimal)` exists in `Extensions.cs` and again in `FileService.cs`.
- **Culture setup in `Program.cs`** runs *after* `MapRazorComponents` and just sets `DefaultThreadCurrentCulture` globally. Should be `RequestLocalizationOptions` middleware.
- **Constructors doing I/O.** `AccountRepository`, `SubclassRepository`, `SplitPredefinitonRepository`, `BookingRuleRepository` call `EnsureFileExisits` in the constructor.
- **Typos leak into public API.** `EnsureFileExisits`, `SplitPredefinitonRepository`.
- **Dead / redundant code.** `AccountRepository.EnsureFileExisits` uses `new` to hide the base method; its `file.IndexOf("Accounts") > -1` guard is redundant. `Home.razor.cs:50` uses a `loaded` flag though `OnInitializedAsync` already runs once per instance. Commented-out `//var subclasses = subclassRepository.GetSubClassList();` in `ChartService`.
- **Legacy nuget packages.** All three library projects reference `System.Text.RegularExpressions 4.3.1`, `System.Xml.XmlSerializer 4.3.0`, `System.ComponentModel.Annotations 5.0.0` — legacy .NET Standard packages, redundant on `net9.0`.
- **`Common.csproj`** has `<Folder Include="Interfaces\Extensions\" />` for a folder that does not exist.
- **`ClassIds`** should be `static class`; currently instantiable.
### 3. Testability
- Zero tests, zero test project.
- Business rules (balance calculation, FX conversion, bank-transaction matching, CSV export, split logic) are entangled with mutable singletons and side effects on returned entities.
---
## Phased plan
### Phase 1 — Stop the bleeding (correctness, low churn)
1. ~~Change `ISettingsService` registration from `Singleton` to `Scoped`.~~ **Deferred.** `MyHeader.YearChanged` uses `NavigateTo(..., forceLoad: true)` after mutating settings, which tears down the SignalR circuit — a Scoped instance would be recreated with default values on the new circuit. Proper fix: persist the year/mandator selection to a cookie or query string, then Scoped becomes safe. Tracked as a new Phase 2 item.
2. Fix `TrySetYear` (capture `this.year` before overwriting). Defer the `ChartService` prior-year-loading pattern to Phase 3.
3. Fix `DummyFxService` typo (or delete the class and replace with a real `IFxService` implementation from `Schaad.Finance.Api`).
4. Remove mutation-on-read in `TransactionRepository.GetTransaction`, `ViewService.GetTransactionViewList(accountId)`, and `FileService.GetTransactionListCsv`.
5. Add a not-found guard to `TransactionRepository.GetTransaction`.
6. De-duplicate the DI registrations. One `AddAccounting()` extension called once in `Program.cs`.
7. Replace `.Class == 3/4` magic numbers with `ClassIds.*`. Update CLAUDE.md's incorrect ClassIds section.
8. Make XML writes atomic: write to `foo.xml.tmp` then `File.Move(..., overwrite: true)`. Wrap Load/Save in a per-file `SemaphoreSlim` (or a simple `lock`).
### Phase 2 — Structural cleanup
9. **Introduce a per-request unit of work / cache.** A scoped `IAccountingContext` that loads each XML file at most once per request and holds the deserialized lists.
10. Replace `Copy(target)` methods with a single merge-in-place pattern (or `record with`).
11. Extract shared formatting to a single `Formatting` helper.
12. Move `IFileService`, `IChartService`, `IViewService` out of `Common` (they depend on `Schaad.Finance.Api`). Common should have no vendor dependency.
13. Fix typos (`EnsureFileExists`, `SplitPredefinitionRepository`).
14. Move file existence bootstrapping out of constructors into a startup step (`IHostedService` or lazy first-use).
15. Fix `AccountRepository.EnsureFileExists` to compute the start-balance updates in memory and save once.
16. Remove legacy NuGet packages. Remove the stale `Interfaces\Extensions\` folder entry.
17. Set culture via `RequestLocalizationOptions` middleware.
18. Add `ILogger<T>` to services and repositories.
18b. Persist selected year and mandator across page reloads (cookie, query string, or `ProtectedLocalStorage`). Prerequisite for making `ISettingsService` `Scoped` (Phase 1 item 1, deferred).
### Phase 3 — Async & performance
19. Convert repository interfaces to async.
20. Cache the current view's data behind the scoped unit-of-work; invalidate on save.
21. Precompute `accountsById` and `subclassNameByNumber` dictionaries once per request.
22. Clean up `ChartService` prior-year loading pattern: introduce an explicit "load year data" helper instead of mutating `ISettingsService`.
### Phase 4 — Testability & safety net
23. Add a `Schaad.Accounting.Tests` xUnit project.
24. First tests: `ViewService.GetBalanceView`, `MatchBankTransactionByBookingRule` / `SameAccountsLastMonth`, `TransactionRepository` FX round-trip, `FileService.GetTransactionListCsv`.
25. Abstract the XML store (`IEntityStore<T>`) so tests use an in-memory store.
### Phase 5 — Nice-to-haves
26. Replace hand-written XML models with `record` types + source-generated serializers.
27. Inject Fixer.io key via `IOptions<FxSettings>` instead of threading through method params.
28. Prune unused Fluent UI packages.
29. Add a health/backup admin page.
---
## PR breakdown
- **PR A** — Phase 1 items 1–7 + CLAUDE.md fix.
- **PR B** — Phase 1 item 8 (atomic writes + lock).
- **PR C** — Phase 2 mechanical cleanup: items 11, 13, 15, 16, 17.
- **PR D** — Phase 2 item 9: per-request unit of work / cache. Split off from PR C because it is invasive enough to warrant its own review.
- **PR E** — Phase 3 item 22: ChartService cleanup. Promoted ahead of async because it fixes an active correctness bug — `GetAccountExpensesPerMonth(accountId, year)` was mutating `ISettingsService` on the singleton to hop years, with the year parameter always hardcoded to `DateTime.Now.Year`, so the Spendings-over-time chart discarded the user's header year selection.
- **PR F** — Phase 3 item 21: precomputed dictionaries in `ViewService` to eliminate O(N·M) `Single(...)` scans and per-account transaction filtering.
- **Item 19 (async I/O)** — deferred. After PR D, each XML file is loaded at most once per SignalR circuit, and this app is single-user local Blazor Server. Converting every repository/service method to async would touch ~40 files for negligible user-visible benefit and real regression risk. Revisit if the app is ever hosted for multiple concurrent users.
- **PR G** — Phase 4 items 23 + 24: `Schaad.Accounting.Tests` xUnit + NSubstitute + Shouldly project. Adopts the `xxxTestShould.DoThisWhenThat` naming convention with Shouldly assertions (no xUnit `Assert.*`). Covers `ViewService`, `TransactionRepository`, `Formatting`, `RepositoryCache`, `AccountRepository`, `ChartService`, and `FileService.GetTransactionListCsv` — 38 tests locking in the earlier PRs' behavior. Note: originally shipped as three separate commits (initial project, expanded coverage, Shouldly + naming conversion) and later squashed into one commit at the user's request.

View file

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

View file

@ -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 ISplitPredefinitonRepository public interface ISplitPredefinitionRepository
{ {
List<SplitPredefinition> GetSplitPredefinitionList(); List<SplitPredefinition> GetSplitPredefinitionList();

View file

@ -4,17 +4,9 @@
<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>

View file

@ -11,20 +11,16 @@ namespace Schaad.Accounting.Repositories
{ {
private readonly string ACCOUNTS = "Accounts.xml"; private readonly string ACCOUNTS = "Accounts.xml";
public AccountRepository(ISettingsService settingsService) : base(settingsService) public AccountRepository(ISettingsService settingsService, RepositoryCache cache)
: base(settingsService, cache)
{ {
EnsureFileExisits(ACCOUNTS); EnsureAccountsFile();
} }
/// <summary> /// <summary>
/// Load accounts /// Load accounts
/// </summary> /// </summary>
public List<Account> GetAccountList() public List<Account> GetAccountList() => LoadList<Account>(ACCOUNTS);
{
var accounts = Load<List<Account>>(ACCOUNTS);
return accounts ?? new List<Account>();
}
/// <summary> /// <summary>
/// Save an account (insert/update) /// Save an account (insert/update)
@ -79,35 +75,40 @@ namespace Schaad.Accounting.Repositories
SaveAccount(account); SaveAccount(account);
} }
private new void EnsureFileExisits(string file) private void EnsureAccountsFile()
{ {
string filePath = Path.Combine(settingsService.GetDbPath(), file); string filePath = Path.Combine(settingsService.GetDbPath(), ACCOUNTS);
if (File.Exists(filePath) == false) if (File.Exists(filePath))
{ {
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), file); return;
if (File.Exists(lastYearFile)) }
File.Copy(lastYearFile, filePath);
// set start balance to last bank balance var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), ACCOUNTS);
if (file.IndexOf("Accounts") > -1) if (File.Exists(lastYearFile))
{
File.Copy(lastYearFile, filePath);
}
// Year rollover: seed start balances from the previous year's closing values.
// Bank accounts start at the last known bank balance; cash accounts start at zero.
var accounts = GetAccountList();
var modified = false;
foreach (var account in accounts.Where(a => a.Class == ClassIds.Activa))
{
if (account.LastBankBalance > 0)
{ {
var accounts = GetAccountList(); account.StartBalance = account.LastBankBalance;
foreach (var account in accounts.Where(a => a.Class == ClassIds.Activa)) modified = true;
{
// bank accounts
if (account.LastBankBalance > 0)
{
account.StartBalance = account.LastBankBalance;
SaveAccount(account);
}
// cash accounts
else if (account.Number < 1010)
{
account.StartBalance = 0;
SaveAccount(account);
}
}
} }
else if (account.Number < 1010)
{
account.StartBalance = 0;
modified = true;
}
}
if (modified)
{
Save(accounts, ACCOUNTS);
} }
} }
} }

View file

@ -10,18 +10,15 @@ namespace Schaad.Accounting.Repositories
{ {
private readonly string BANK_TRANSACTIONS = "BankTransactions.xml"; private readonly string BANK_TRANSACTIONS = "BankTransactions.xml";
public BankTransactionRepository(ISettingsService settingsService) : base(settingsService) public BankTransactionRepository(ISettingsService settingsService, RepositoryCache cache)
: base(settingsService, cache)
{ {
} }
/// <summary> /// <summary>
/// Load bank transactions /// Load bank transactions
/// </summary> /// </summary>
public List<BankTransaction> GetBankTransactionList() public List<BankTransaction> GetBankTransactionList() => LoadList<BankTransaction>(BANK_TRANSACTIONS);
{
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)

View file

@ -1,4 +1,7 @@
using System.IO; using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Text; using System.Text;
using System.Xml; using System.Xml;
using System.Xml.Serialization; using System.Xml.Serialization;
@ -8,65 +11,111 @@ namespace Schaad.Accounting.Repositories
{ {
public abstract class BaseRepository public abstract class BaseRepository
{ {
protected readonly ISettingsService settingsService; // One lock per absolute file path so concurrent Save+Save and Save+Load are serialized
// and can't observe a half-written file.
private static readonly ConcurrentDictionary<string, object> FileLocks =
new(StringComparer.OrdinalIgnoreCase);
protected BaseRepository(ISettingsService settingsService) protected readonly ISettingsService settingsService;
protected readonly RepositoryCache cache;
protected BaseRepository(ISettingsService settingsService, RepositoryCache cache)
{ {
this.settingsService = settingsService; this.settingsService = settingsService;
this.cache = cache;
} }
protected void EnsureFileExisits(string fileName) protected void EnsureFileExists(string fileName)
{ {
string filePath = Path.Combine(settingsService.GetDbPath(), fileName); string filePath = Path.Combine(settingsService.GetDbPath(), fileName);
if (File.Exists(filePath) == false) lock (GetLock(filePath))
{ {
var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), fileName); if (File.Exists(filePath) == false)
if (File.Exists(lastYearFile))
{ {
File.Copy(lastYearFile, filePath); var lastYearFile = Path.Combine(settingsService.GetLastYearDbPath(), fileName);
if (File.Exists(lastYearFile))
{
File.Copy(lastYearFile, filePath);
}
} }
} }
} }
/// <summary> /// <summary>
/// Save an object to an xml file /// Load a list, cached per scope. First call reads the XML file; subsequent calls in
/// the same scope return the cached list. <see cref="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);
using (var sww = new MemoryStream()) var tmpPath = filePath + ".tmp";
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
{ {
var xsSubmit = new XmlSerializer(typeof(T)); using (var writer = XmlWriter.Create(tmpPath, settings))
xsSubmit.Serialize(writer, obj); {
var xml = Encoding.UTF8.GetString(sww.ToArray()); var serializer = new XmlSerializer(typeof(T));
File.WriteAllText(filePath, xml); serializer.Serialize(writer, obj);
}
File.Move(tmpPath, filePath, overwrite: true);
}
catch
{
if (File.Exists(tmpPath))
{
try { File.Delete(tmpPath); } catch { /* best effort */ }
}
throw;
} }
} }
cache.Invalidate(filePath);
} }
/// <summary> /// <summary>
/// Load an object from an xml file /// Load an object from an xml file (uncached; used for one-off reads such as file
/// existence bootstrapping). Prefer <see cref="LoadList{T}"/> for repository reads.
/// </summary> /// </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)
{
return default(T);
}
using (XmlReader reader = XmlReader.Create(filePath)) lock (GetLock(filePath))
{ {
var serializer = new XmlSerializer(typeof(T)); if (File.Exists(filePath) == false)
return (T)serializer.Deserialize(reader); {
return default(T);
}
using (XmlReader reader = XmlReader.Create(filePath))
{
var serializer = new XmlSerializer(typeof(T));
return (T)serializer.Deserialize(reader);
}
} }
} }
private static object GetLock(string filePath)
=> FileLocks.GetOrAdd(filePath, _ => new object());
} }
} }

View file

@ -10,19 +10,16 @@ namespace Schaad.Accounting.Repositories
{ {
private readonly string BOOKING_RULES = "BookingRules.xml"; private readonly string BOOKING_RULES = "BookingRules.xml";
public BookingRuleRepository(ISettingsService settingsService) : base(settingsService) public BookingRuleRepository(ISettingsService settingsService, RepositoryCache cache)
: base(settingsService, cache)
{ {
EnsureFileExisits(BOOKING_RULES); EnsureFileExists(BOOKING_RULES);
} }
/// <summary> /// <summary>
/// Load booking rules /// Load booking rules
/// </summary> /// </summary>
public List<BookingRule> GetBookingRuleList() public List<BookingRule> GetBookingRuleList() => LoadList<BookingRule>(BOOKING_RULES);
{
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)

View file

@ -10,19 +10,16 @@ namespace Schaad.Accounting.Repositories
{ {
private readonly string BOOKING_TEXTS = "BookingTexts.xml"; private readonly string BOOKING_TEXTS = "BookingTexts.xml";
public BookingTextRepository(ISettingsService settingsService) : base(settingsService) public BookingTextRepository(ISettingsService settingsService, RepositoryCache cache)
: base(settingsService, cache)
{ {
EnsureFileExisits(BOOKING_TEXTS); EnsureFileExists(BOOKING_TEXTS);
} }
/// <summary> /// <summary>
/// Load bokking texts /// Load booking texts
/// </summary> /// </summary>
public List<BookingText> GetBookingTextList() public List<BookingText> GetBookingTextList() => LoadList<BookingText>(BOOKING_TEXTS);
{
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)

View file

@ -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,26 +6,23 @@ using Schaad.Accounting.Models;
namespace Schaad.Accounting.Repositories namespace Schaad.Accounting.Repositories
{ {
public class SplitPredefinitonRepository : BaseRepository, ISplitPredefinitonRepository public class SplitPredefinitionRepository : BaseRepository, ISplitPredefinitionRepository
{ {
private readonly string SPLIT_PREDEFINITION = "SplitPredefinitions.xml"; private readonly string SPLIT_PREDEFINITION = "SplitPredefinitions.xml";
public SplitPredefinitonRepository(ISettingsService settingsService) : base(settingsService) public SplitPredefinitionRepository(ISettingsService settingsService, RepositoryCache cache)
: base(settingsService, cache)
{ {
EnsureFileExisits(SPLIT_PREDEFINITION); EnsureFileExists(SPLIT_PREDEFINITION);
} }
/// <summary> /// <summary>
/// Load booking rules /// Load split predefinitions
/// </summary> /// </summary>
public List<SplitPredefinition> GetSplitPredefinitionList() public List<SplitPredefinition> GetSplitPredefinitionList() => LoadList<SplitPredefinition>(SPLIT_PREDEFINITION);
{
var definitions = Load<List<SplitPredefinition>>(SPLIT_PREDEFINITION);
return definitions ?? new List<SplitPredefinition>();
}
/// <summary> /// <summary>
/// Save a booking rule (insert/update) /// Save a split predefinition (insert/update)
/// </summary> /// </summary>
public void SaveSplitPredefinition(SplitPredefinition splitPredefinition) public void SaveSplitPredefinition(SplitPredefinition splitPredefinition)
{ {

View file

@ -11,9 +11,10 @@ namespace Schaad.Accounting.Repositories
private readonly Dictionary<int, string> classes = new Dictionary<int, string>(); private readonly Dictionary<int, string> classes = new Dictionary<int, string>();
private readonly string SUBCLASSES = "SubClasses.xml"; private readonly string SUBCLASSES = "SubClasses.xml";
public SubclassRepository(ISettingsService settingsService) : base(settingsService) public SubclassRepository(ISettingsService settingsService, RepositoryCache cache)
: base(settingsService, cache)
{ {
EnsureFileExisits(SUBCLASSES); EnsureFileExists(SUBCLASSES);
classes.Add(ClassIds.Activa, "Aktiven"); classes.Add(ClassIds.Activa, "Aktiven");
classes.Add(ClassIds.Passiva, "Passiven"); classes.Add(ClassIds.Passiva, "Passiven");
@ -22,13 +23,9 @@ namespace Schaad.Accounting.Repositories
} }
/// <summary> /// <summary>
/// Load bokking texts /// Load subclasses
/// </summary> /// </summary>
public List<SubClass> GetSubClassList() public List<SubClass> GetSubClassList() => LoadList<SubClass>(SUBCLASSES);
{
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)

View file

@ -11,7 +11,8 @@ namespace Schaad.Accounting.Repositories
private readonly IAccountRepository accountRepository; private readonly IAccountRepository accountRepository;
private readonly string TRANSACTIONS = "Transactions.xml"; private readonly string TRANSACTIONS = "Transactions.xml";
public TransactionRepository(ISettingsService settingsService, IAccountRepository accountRepository) : base(settingsService) public TransactionRepository(ISettingsService settingsService, RepositoryCache cache, IAccountRepository accountRepository)
: base(settingsService, cache)
{ {
this.accountRepository = accountRepository; this.accountRepository = accountRepository;
} }
@ -19,11 +20,7 @@ namespace Schaad.Accounting.Repositories
/// <summary> /// <summary>
/// Get transaction list /// Get transaction list
/// </summary> /// </summary>
public List<Transaction> GetTransactionList() public List<Transaction> GetTransactionList() => LoadList<Transaction>(TRANSACTIONS);
{
var transactionList = Load<List<Transaction>>(TRANSACTIONS);
return transactionList ?? new List<Transaction>();
}
/// <summary> /// <summary>
/// Save a transaction (insert/update) /// Save a transaction (insert/update)
@ -67,17 +64,24 @@ namespace Schaad.Accounting.Repositories
public Transaction GetTransaction(string id) public Transaction GetTransaction(string id)
{ {
var transactions = GetTransactionList(); var transactions = GetTransactionList();
var transaction = transactions.FirstOrDefault(t => t.Id == id); var stored = transactions.FirstOrDefault(t => t.Id == id);
if (stored == null)
// value is stored in CHF -> convert back to foreign currency for display/editing
var isFxAccount = accountRepository.GetAccount(transaction.OriginAccountId).IsFxAccount
|| accountRepository.GetAccount(transaction.TargetAccountId).IsFxAccount;
if (isFxAccount && transaction.FxRate != 0)
{ {
transaction.Value = transaction.Value / transaction.FxRate.Value; return null;
} }
return transaction; var result = new Transaction();
stored.Copy(result);
// value is stored in CHF -> convert back to foreign currency for display/editing
var isFxAccount = accountRepository.GetAccount(result.OriginAccountId).IsFxAccount
|| accountRepository.GetAccount(result.TargetAccountId).IsFxAccount;
if (isFxAccount && result.FxRate != 0)
{
result.Value = result.Value / result.FxRate.Value;
}
return result;
} }
/// <summary> /// <summary>

View file

@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
namespace Schaad.Accounting.Repositories
{
/// <summary>
/// Per-scope cache of deserialized XML lists, keyed by absolute file path.
///
/// Registered as Scoped so it lives for the SignalR circuit; each XML file is
/// then loaded at most once per circuit. Writes go through <see cref="BaseRepository.Save"/>,
/// which invalidates the corresponding entry so the next read reloads from disk.
///
/// Not thread-safe — callers within a single Blazor Server circuit run serialized on the
/// circuit's synchronization context, so no locking is needed.
/// </summary>
public class RepositoryCache
{
private readonly Dictionary<string, object> entries =
new(StringComparer.OrdinalIgnoreCase);
public List<T> GetOrLoad<T>(string cacheKey, Func<List<T>> loader)
{
if (entries.TryGetValue(cacheKey, out var cached))
{
return (List<T>)cached;
}
var list = loader();
entries[cacheKey] = list;
return list;
}
public void Invalidate(string cacheKey)
{
entries.Remove(cacheKey);
}
}
}

View file

@ -4,9 +4,6 @@
<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>

View file

@ -1,6 +1,7 @@
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;
@ -8,115 +9,88 @@ namespace Schaad.Accounting.Services
{ {
public class ChartService : IChartService public class ChartService : IChartService
{ {
private readonly IAccountRepository accountRepository;
private readonly ISettingsService settingsService; private readonly ISettingsService settingsService;
private readonly ISubclassRepository subclassRepository;
private readonly IViewService viewService; private readonly IViewService viewService;
public ChartService( public ChartService(ISettingsService settingsService, IViewService viewService)
ISettingsService settingsService,
IViewService viewService,
IAccountRepository accountRepository,
ISubclassRepository subclassRepository)
{ {
this.settingsService = settingsService; this.settingsService = settingsService;
this.viewService = viewService; this.viewService = viewService;
this.accountRepository = accountRepository;
this.subclassRepository = subclassRepository;
} }
public IReadOnlyList<DataSerie> GetExpensesPerMonth() public IReadOnlyList<DataSerie> GetExpensesPerMonth()
{ {
var transactions = viewService.GetTransactionViewList().Where(a => a.TargetAccount.Class == ClassIds.Expenses); var expenseTransactions = viewService.GetTransactionViewList()
.Where(t => t.TargetAccount.Class == ClassIds.Expenses)
.ToList();
// More than one subclass xx var distinctSubClasses = expenseTransactions
if (transactions.Select(t => t.TargetAccount.SubClass).Distinct().Count() > 1) .Select(t => t.TargetAccount.SubClass)
{ .Distinct()
return GetSubClassExpensesPerMonth(); .Count();
}
// only one subclass xx (z.B. Mandant Mannenbach) return distinctSubClasses > 1
return GetAccountExpensesPerMonth(); ? GetSubClassExpensesPerMonth(expenseTransactions)
: GetAccountExpensesPerMonth();
} }
private List<DataSerie> GetSubClassExpensesPerMonth() private List<DataSerie> GetSubClassExpensesPerMonth(List<TransactionDataset> expenseTransactions)
{ {
//var subclasses = subclassRepository.GetSubClassList();
var transactions = viewService.GetTransactionViewList().Where(a => a.TargetAccount.Class == ClassIds.Expenses).ToList();
var list = new List<DataSerie>();
var newestTransaction = transactions.OrderByDescending(t => t.ValueDate).FirstOrDefault();
var maxMonth = newestTransaction?.ValueDate.Month ?? 12;
var year = settingsService.GetYear(); var year = settingsService.GetYear();
var maxMonth = expenseTransactions
.OrderByDescending(t => t.ValueDate)
.FirstOrDefault()?.ValueDate.Month ?? 12;
// Group by subclass var list = new List<DataSerie>();
foreach (var grp in transactions.GroupBy(a => a.TargetAccount.SubClass).Select(a => new {Key = a.Key, List = a.ToList()})) foreach (var grp in expenseTransactions.GroupBy(t => t.TargetAccount.SubClass))
{ {
// Sum subclass transactions per month var groupedByMonth = grp
var groupedByMonth = grp.List.GroupBy(g => g.ValueDate.Month).ToDictionary(g => g.Key, g => g.ToList().Sum(s => s.Value)); .GroupBy(t => t.ValueDate.Month)
.ToDictionary(g => g.Key, g => g.Sum(t => t.Value));
EnsureEntryForEveryMonth(groupedByMonth, maxMonth); EnsureEntryForEveryMonth(groupedByMonth, maxMonth);
//var subClass = subclasses.FirstOrDefault(s => s.Number == grp.List.First().TargetAccount.SubClass); list.Add(new DataSerie(
list.Add( Id: grp.Key.ToString(),
new DataSerie( Name: grp.First().TargetAccount.Name,
Id: grp.List.First().TargetAccount.SubClass.ToString(), X: groupedByMonth.OrderBy(g => g.Key).Select(g => new DateOnly(year, g.Key, 1)).ToList(),
Name: grp.List.First().TargetAccount.Name, Y: groupedByMonth.OrderBy(g => g.Key).Select(g => g.Value).ToList()
X: groupedByMonth.OrderBy(g => g.Key).Select(g => new DateOnly(year, g.Key, 1)).ToList(), ));
Y: groupedByMonth.OrderBy(g => g.Key).Select(g => g.Value).ToList()
)
);
} }
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>();
var expensesAccounts = viewService.GetAccountViewList().Where(a => a.Class == ClassIds.Expenses); foreach (var account in expenseAccounts)
foreach (var account in expensesAccounts)
{ {
var serie = GetAccountExpensesPerMonth(account.Id, DateTime.Now.Year); var transactions = allTransactions.Where(t => t.TargetAccountId == account.Id).ToList();
if (serie != null) if (transactions.Count == 0)
{ {
list.Add(serie); continue;
} }
var maxMonth = transactions.Max(t => t.ValueDate.Month);
var groupedByMonth = transactions
.GroupBy(t => t.ValueDate.Month)
.ToDictionary(g => g.Key, g => g.Sum(t => t.Value));
EnsureEntryForEveryMonth(groupedByMonth, maxMonth);
list.Add(new DataSerie(
Id: account.Id,
Name: account.Name,
X: groupedByMonth.OrderBy(g => g.Key).Select(g => new DateOnly(year, g.Key, 1)).ToList(),
Y: groupedByMonth.OrderBy(g => g.Key).Select(g => g.Value).ToList()
));
} }
return list; return list;
} }
private DataSerie GetAccountExpensesPerMonth(string accountId, int year) private static void EnsureEntryForEveryMonth(Dictionary<int, decimal> values, int maxMonth = 12)
{
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++)
{ {

View file

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

View file

@ -1,5 +1,4 @@
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;
@ -130,28 +129,22 @@ namespace Schaad.Accounting.Services
{ {
var credit = ""; var credit = "";
var debit = ""; var debit = "";
var signedValue = trx.Value;
if (trx.OriginAccountId == accountId) if (trx.OriginAccountId == accountId)
{ {
debit = ToFormattedString(trx.Value); debit = trx.Value.ToFormattedString();
trx.Value *= -1; signedValue = -trx.Value;
} }
else else
{ {
credit = ToFormattedString(trx.Value); credit = trx.Value.ToFormattedString();
} }
balance += trx.Value; balance += signedValue;
sb.AppendLine($"{trx.BookingDate:dd.MM.yyyy};{trx.ValueDate:dd.MM.yyyy};{trx.Text};{debit};{credit};{ToFormattedString(balance)}"); sb.AppendLine($"{trx.BookingDate:dd.MM.yyyy};{trx.ValueDate:dd.MM.yyyy};{trx.Text};{debit};{credit};{balance.ToFormattedString()}");
} }
var fileBytes = Encoding.GetEncoding("ISO-8859-1").GetBytes(sb.ToString()); var fileBytes = Encoding.GetEncoding("ISO-8859-1").GetBytes(sb.ToString());
return fileBytes; return fileBytes;
} }
private string ToFormattedString(decimal value)
{
var culture = new CultureInfo("de-CH");
culture.NumberFormat.NumberGroupSeparator = "'";
return value.ToString("#,0.00", culture);
}
} }
} }

View file

@ -6,7 +6,6 @@
<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" />

View file

@ -30,7 +30,7 @@ namespace Schaad.Accounting.Services
public bool TrySetYear(int year) public bool TrySetYear(int year)
{ {
var oldYear = year; var oldYear = this.year;
SetYear(year); SetYear(year);
if (Directory.GetFiles(GetDbPath()).Any() == false) if (Directory.GetFiles(GetDbPath()).Any() == false)
{ {

View file

@ -57,22 +57,43 @@ 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();
var accountList = accounts.Select( // Group transactions by account so per-account balance is O(k) instead of O(M).
a => var creditsByAccount = transactionList.GroupBy(t => t.TargetAccountId).ToDictionary(g => g.Key, g => g.ToList());
new AccountDataset( var debitsByAccount = transactionList.GroupBy(t => t.OriginAccountId).ToDictionary(g => g.Key, g => g.ToList());
account: a,
balance: GetBalanceInAccountCurrency(a, transactionList), var accountList = accounts.Select(a =>
balanceCHF: GetCurrentBalanceInCHF(a, transactionList), {
startBalanceCHF: fxService.ConvertCurrency(a.StartBalance, a.Currency, "CHF", settings.FixerIoApiKey), var balance = ComputeBalance(a, creditsByAccount, debitsByAccount);
className: subclassRepository.GetClass(a.Class), return new AccountDataset(
subClassName: subClassNameByNumber[a.SubClass] account: a,
) balance: balance,
) balanceCHF: 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();
@ -107,40 +128,20 @@ 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 accountList = accountRepository.GetAccountList(); var accountsById = accountRepository.GetAccountList().ToDictionary(a => a.Id);
var transactionList = transactionRepository.GetTransactionList(); var transactionList = transactionRepository.GetTransactionList();
var transactionViewList = transactionList.Select( return transactionList.Select(t =>
t => new TransactionDataset(
new TransactionDataset( t,
t, accountsById[t.OriginAccountId],
accountList.Single(a => a.Id == t.OriginAccountId), accountsById[t.TargetAccountId]))
accountList.Single(a => a.Id == t.TargetAccountId))
)
.ToList(); .ToList();
return transactionViewList;
} }
/// <summary> /// <summary>
@ -148,50 +149,41 @@ namespace Schaad.Accounting.Services
/// </summary> /// </summary>
public List<TransactionDataset> GetTransactionViewList(string accountId) public List<TransactionDataset> GetTransactionViewList(string accountId)
{ {
var accountList = accountRepository.GetAccountList(); var accountsById = accountRepository.GetAccountList().ToDictionary(a => a.Id);
var transactionList = transactionRepository.GetTransactionList().Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId); var transactionList = transactionRepository.GetTransactionList()
.Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId);
var transactionViewList = transactionList.Select( return transactionList.Select(t =>
t => new TransactionDataset(
new TransactionDataset( WithDisplaySign(t),
Prepare(t), accountsById[t.OriginAccountId],
accountList.Single(a => a.Id == t.OriginAccountId), accountsById[t.TargetAccountId]))
accountList.Single(a => a.Id == t.TargetAccountId)
)
)
.ToList(); .ToList();
return transactionViewList; Transaction WithDisplaySign(Transaction t)
Transaction Prepare(Transaction t)
{ {
var account = accountList.Single(a => a.Id == t.OriginAccountId); var originAccount = accountsById[t.OriginAccountId];
if (originAccount.Class == ClassIds.Activa && accountId == t.OriginAccountId)
if (account.Class == ClassIds.Activa && accountId == t.OriginAccountId )
{ {
t.Value *= -1; var copy = new Transaction();
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 accountList = accountRepository.GetAccountList(); var accountsById = accountRepository.GetAccountList().ToDictionary(a => a.Id);
var bookinRuleList = bookingRuleRepository.GetBookingRuleList(); var bookingRules = bookingRuleRepository.GetBookingRuleList();
return bookinRuleList.Select( return bookingRules.Select(t =>
t => new BookingRuleDataset(t, accountsById[t.AccountId].Name))
new BookingRuleDataset(
t,
accountList.Single(a => a.Id == t.AccountId).Name
)
)
.ToList(); .ToList();
} }

View file

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

View file

@ -0,0 +1,132 @@
using NSubstitute;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.Services;
using Shouldly;
namespace Schaad.Accounting.Tests;
public class ChartServiceTestShould
{
private readonly ISettingsService settingsService = Substitute.For<ISettingsService>();
private readonly IViewService viewService = Substitute.For<IViewService>();
public ChartServiceTestShould()
{
settingsService.GetYear().Returns(2026);
}
private ChartService BuildService() => new(settingsService, viewService);
[Fact]
public void HonourSelectedYearWhenBuildingMonthlySeries()
{
settingsService.GetYear().Returns(2023);
var groceries = MakeAccount("groc", 4200);
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewList().Returns(new List<AccountDataset> { AsDataset(groceries), AsDataset(checking) });
viewService.GetTransactionViewList().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2023, 4, 5))
});
var series = BuildService().GetExpensesPerMonth();
series.Count.ShouldBe(1);
series[0].X.ShouldAllBe(x => ((DateOnly)x).Year == 2023);
settingsService.DidNotReceive().SetYear(Arg.Any<int>());
settingsService.DidNotReceive().TrySetYear(Arg.Any<int>());
}
[Fact]
public void GroupExpensesByAccountWhenAllExpensesShareOneSubClass()
{
var mannenbach = MakeAccount("m1", 4000); // SubClass 40
var mannenbach2 = MakeAccount("m2", 4001); // SubClass 40 (same)
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewList().Returns(new List<AccountDataset>
{
AsDataset(mannenbach), AsDataset(mannenbach2), AsDataset(checking)
});
viewService.GetTransactionViewList().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, mannenbach, value: 10m, valueDate: new DateTime(2026, 1, 1)),
MakeTransaction(checking, mannenbach2, value: 20m, valueDate: new DateTime(2026, 1, 1))
});
var series = BuildService().GetExpensesPerMonth();
series.Count.ShouldBe(2);
series.ShouldContain(s => s.Id == "m1");
series.ShouldContain(s => s.Id == "m2");
}
[Fact]
public void GroupExpensesBySubClassWhenMultipleSubClassesAreUsed()
{
var groceries = MakeAccount("groc", 4200); // SubClass 42
var utilities = MakeAccount("util", 4300); // SubClass 43
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewList().Returns(new List<AccountDataset>
{
AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
});
viewService.GetTransactionViewList().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 1)),
MakeTransaction(checking, utilities, value: 30m, valueDate: new DateTime(2026, 2, 1))
});
var series = BuildService().GetExpensesPerMonth();
series.Count.ShouldBe(2);
series.ShouldContain(s => s.Id == "42"); // sub-class number as id
series.ShouldContain(s => s.Id == "43");
}
[Fact]
public void SkipAccountsWithoutTransactionsWhenBuildingSeries()
{
var groceries = MakeAccount("groc", 4000); // has activity
var utilities = MakeAccount("util", 4001); // same subclass, no activity
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewList().Returns(new List<AccountDataset>
{
AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
});
viewService.GetTransactionViewList().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 1))
});
var series = BuildService().GetExpensesPerMonth();
series.Count.ShouldBe(1);
series[0].Id.ShouldBe("groc");
}
private static Account MakeAccount(string id, int number) => new()
{
Id = id,
Number = number,
Name = id,
Currency = "CHF"
};
private static AccountDataset AsDataset(Account a) =>
new(a, balance: 0, balanceCHF: 0, startBalanceCHF: 0, className: "", subClassName: "");
private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, DateTime valueDate) =>
new(
new Transaction
{
Id = Guid.NewGuid().ToString(),
OriginAccountId = origin.Id,
TargetAccountId = target.Id,
Value = value,
ValueDate = valueDate,
BookingDate = valueDate
},
origin, target);
}

View file

@ -0,0 +1,106 @@
using System.Text;
using NSubstitute;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.Services;
using Schaad.Finance.Api;
using Shouldly;
namespace Schaad.Accounting.Tests;
public class FileServiceTestShould
{
private readonly ISettingsService settingsService = Substitute.For<ISettingsService>();
private readonly IAccountRepository accountRepo = Substitute.For<IAccountRepository>();
private readonly ITransactionRepository transactionRepo = Substitute.For<ITransactionRepository>();
private readonly IBankTransactionRepository bankTransactionRepo = Substitute.For<IBankTransactionRepository>();
private readonly IAccountStatementService accountStatementService = Substitute.For<IAccountStatementService>();
private readonly ICreditCardStatementService creditCardStatementService = Substitute.For<ICreditCardStatementService>();
public FileServiceTestShould()
{
// Registering the ISO-8859-1 code page provider so the CSV encoding call works
// in the test host (which does not include legacy encodings by default).
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
private FileService BuildService() => new(
settingsService, accountRepo, transactionRepo, bankTransactionRepo,
accountStatementService, creditCardStatementService);
[Fact]
public void EmitHeaderRowAndRunningBalanceWhenExportingTransactionsCsv()
{
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF", StartBalance = 1000m };
var groceries = new Account { Id = "groc", Number = 4200, Name = "Groceries", Currency = "CHF" };
var salary = new Account { Id = "sal", Number = 3000, Name = "Salary", Currency = "CHF" };
accountRepo.GetAccount("chk").Returns(checking);
transactionRepo.GetTransactionList().Returns(new List<Transaction>
{
new()
{
Id = "t1", OriginAccountId = "chk", TargetAccountId = "groc", Value = 50m, Text = "Coop",
BookingDate = new DateTime(2026, 1, 5), ValueDate = new DateTime(2026, 1, 5)
},
new()
{
Id = "t2", OriginAccountId = "sal", TargetAccountId = "chk", Value = 5000m, Text = "Salary",
BookingDate = new DateTime(2026, 1, 25), ValueDate = new DateTime(2026, 1, 25)
}
});
var bytes = BuildService().GetTransactionListCsv("chk");
var csv = Encoding.GetEncoding("ISO-8859-1").GetString(bytes);
var lines = csv.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
lines[0].ShouldBe("Buchungsdatum;Valuta;Buchungstext;Belastung;Gutschrift;Saldo CHF");
lines[1].ShouldBe(";;Startsaldo;;;1000");
// Debit line (outgoing) — value in Belastung column, balance drops
lines[2].ShouldBe("05.01.2026;05.01.2026;Coop;50.00;;950.00");
// Credit line (incoming) — value in Gutschrift column, balance rises
lines[3].ShouldBe("25.01.2026;25.01.2026;Salary;;5'000.00;5'950.00");
}
[Fact]
public void OrderTransactionsByBookingDateThenValueDateThenValueWhenExporting()
{
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF", StartBalance = 0m };
accountRepo.GetAccount("chk").Returns(checking);
transactionRepo.GetTransactionList().Returns(new List<Transaction>
{
// Deliberately unordered
new() { OriginAccountId = "chk", TargetAccountId = "x", Value = 10m, Text = "later",
BookingDate = new DateTime(2026, 2, 1), ValueDate = new DateTime(2026, 2, 1) },
new() { OriginAccountId = "chk", TargetAccountId = "x", Value = 5m, Text = "earlier small",
BookingDate = new DateTime(2026, 1, 15), ValueDate = new DateTime(2026, 1, 15) },
new() { OriginAccountId = "chk", TargetAccountId = "x", Value = 20m, Text = "earlier large",
BookingDate = new DateTime(2026, 1, 15), ValueDate = new DateTime(2026, 1, 15) }
});
var csv = Encoding.GetEncoding("ISO-8859-1").GetString(BuildService().GetTransactionListCsv("chk"));
var lines = csv.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
// Skip header + start balance rows
lines[2].ShouldContain("earlier small");
lines[3].ShouldContain("earlier large");
lines[4].ShouldContain("later");
}
[Fact]
public void NotMutateTransactionValueWhenExportingCsv()
{
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF" };
accountRepo.GetAccount("chk").Returns(checking);
var outgoing = new Transaction
{
OriginAccountId = "chk", TargetAccountId = "x", Value = 42m, Text = "test",
BookingDate = new DateTime(2026, 1, 1), ValueDate = new DateTime(2026, 1, 1)
};
transactionRepo.GetTransactionList().Returns(new List<Transaction> { outgoing });
BuildService().GetTransactionListCsv("chk");
// Regression: earlier code did `trx.Value *= -1` mid-loop.
outgoing.Value.ShouldBe(42m);
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,138 @@
using NSubstitute;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.Repositories;
using Shouldly;
namespace Schaad.Accounting.Tests;
public class TransactionRepositoryTestShould : IDisposable
{
private readonly string dbDir;
private readonly ISettingsService settingsService;
private readonly IAccountRepository accountRepo;
private readonly RepositoryCache cache;
private readonly TransactionRepository sut;
public TransactionRepositoryTestShould()
{
dbDir = Path.Combine(Path.GetTempPath(), "AccountingNext-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dbDir);
settingsService = Substitute.For<ISettingsService>();
settingsService.GetDbPath().Returns(dbDir);
settingsService.GetLastYearDbPath().Returns(Path.Combine(dbDir, "no-such-dir"));
accountRepo = Substitute.For<IAccountRepository>();
cache = new RepositoryCache();
sut = new TransactionRepository(settingsService, cache, accountRepo);
}
public void Dispose()
{
if (Directory.Exists(dbDir))
{
Directory.Delete(dbDir, recursive: true);
}
}
[Fact]
public void PreserveValueWhenRoundTrippingNonFxTransaction()
{
accountRepo.GetAccount("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
accountRepo.GetAccount("groc").Returns(new Account { Id = "groc", Currency = "CHF" });
sut.SaveTransaction(new Transaction
{
OriginAccountId = "chk",
TargetAccountId = "groc",
Value = 42m,
Text = "Groceries",
ValueDate = new DateTime(2026, 5, 10),
BookingDate = new DateTime(2026, 5, 10)
});
var stored = sut.GetTransactionList().Single();
var loaded = sut.GetTransaction(stored.Id);
loaded.ShouldNotBeNull();
loaded.Value.ShouldBe(42m);
loaded.FxRate.ShouldBeNull(); // Non-FX transactions have FxRate cleared
}
[Fact]
public void StoreInChfAndReturnInAccountCurrencyWhenTransactionIsFx()
{
// User enters 100 USD at rate 0.9 CHF/USD -> stored as 90 CHF; read back as 100 USD.
accountRepo.GetAccount("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
accountRepo.GetAccount("usd").Returns(new Account { Id = "usd", Currency = "USD" });
sut.SaveTransaction(new Transaction
{
OriginAccountId = "chk",
TargetAccountId = "usd",
Value = 100m,
FxRate = 0.9m,
Text = "USD transfer",
ValueDate = new DateTime(2026, 5, 10),
BookingDate = new DateTime(2026, 5, 10)
});
var stored = sut.GetTransactionList().Single();
stored.Value.ShouldBe(90m); // stored in CHF
var loaded = sut.GetTransaction(stored.Id);
loaded.ShouldNotBeNull();
loaded.Value.ShouldBe(100m); // display currency
loaded.FxRate.ShouldBe(0.9m);
}
[Fact]
public void ReturnNullWhenGettingUnknownTransactionId()
{
sut.GetTransaction("no-such-id").ShouldBeNull();
}
[Fact]
public void NotMutateStoredValueWhenGettingFxTransactionTwice()
{
accountRepo.GetAccount("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
accountRepo.GetAccount("usd").Returns(new Account { Id = "usd", Currency = "USD" });
sut.SaveTransaction(new Transaction
{
OriginAccountId = "chk",
TargetAccountId = "usd",
Value = 100m,
FxRate = 0.9m,
Text = "USD transfer",
ValueDate = new DateTime(2026, 5, 10),
BookingDate = new DateTime(2026, 5, 10)
});
var stored = sut.GetTransactionList().Single();
var id = stored.Id;
// Get twice; if Get mutated the stored entity, the second call would divide by FxRate again.
var first = sut.GetTransaction(id);
var second = sut.GetTransaction(id);
first.ShouldNotBeNull();
first.Value.ShouldBe(100m);
second.ShouldNotBeNull();
second.Value.ShouldBe(100m);
}
[Fact]
public void RemoveTransactionWhenDeletingById()
{
accountRepo.GetAccount(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
sut.SaveTransaction(new Transaction { OriginAccountId = "a", TargetAccountId = "b", Value = 1m, Text = "x", ValueDate = DateTime.Today });
var id = sut.GetTransactionList().Single().Id;
sut.DeleteTransaction(id);
sut.GetTransactionList().ShouldBeEmpty();
}
}

View file

@ -0,0 +1,211 @@
using NSubstitute;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.Services;
using Schaad.Finance.Api;
using Shouldly;
namespace Schaad.Accounting.Tests;
public class ViewServiceTestShould
{
private readonly IAccountRepository accountRepo = Substitute.For<IAccountRepository>();
private readonly ITransactionRepository transactionRepo = Substitute.For<ITransactionRepository>();
private readonly IBankTransactionRepository bankTransactionRepo = Substitute.For<IBankTransactionRepository>();
private readonly IBookingRuleRepository bookingRuleRepo = Substitute.For<IBookingRuleRepository>();
private readonly ISubclassRepository subclassRepo = Substitute.For<ISubclassRepository>();
private readonly IFxService fxService = Substitute.For<IFxService>();
private readonly ISettingsService settingsService = Substitute.For<ISettingsService>();
public ViewServiceTestShould()
{
// Cover every subclass number a test might reference (Account.SubClass = Number / 100).
subclassRepo.GetSubClassList().Returns(
Enumerable.Range(10, 50).Select(n => new SubClass { Number = n, Name = "sub-" + n }).ToList());
subclassRepo.GetClass(Arg.Any<int>()).Returns("");
settingsService.GetSettings().Returns(new SettingsDataset { DataPath = "", FixerIoApiKey = "" });
// Passthrough FX by default (CHF-only). Individual tests can override.
fxService.ConvertCurrency(Arg.Any<decimal>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(ci => ci.ArgAt<decimal>(0));
}
private ViewService BuildService() =>
new(accountRepo, bankTransactionRepo, transactionRepo, subclassRepo, bookingRuleRepo, fxService, settingsService);
// --- Balance math ---------------------------------------------------------
[Fact]
public void SumActivaAndPassivaTotalsSeparatelyWhenGettingBalanceView()
{
var cash = Account("cash", 1000, "CHF", startBalance: 100m);
var receivables = Account("recv", 1100, "CHF", startBalance: 50m);
var payables = Account("pay", 2000, "CHF", startBalance: -200m);
accountRepo.GetAccountList().Returns(new List<Account> { cash, receivables, payables });
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
var view = BuildService().GetBalanceView();
view.ActivaAccountList.Count.ShouldBe(2);
view.PassivaAccountList.Count.ShouldBe(1);
view.TotalActivaCHF.ShouldBe(150m);
view.TotalPassivaCHF.ShouldBe(200m);
}
[Fact]
public void ApplyTransactionsToStartBalanceWhenComputingAccountBalance()
{
var cash = Account("cash", 1000, "CHF", startBalance: 1000m);
var groceries = Account("groc", 4000, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { cash, groceries });
// Debit cash 50, credit groceries 50 (spending)
transactionRepo.GetTransactionList().Returns(new List<Transaction>
{
new() { Id = "t1", OriginAccountId = "cash", TargetAccountId = "groc", Value = 50m, ValueDate = new DateTime(2026, 1, 1) },
new() { Id = "t2", OriginAccountId = "cash", TargetAccountId = "groc", Value = 30m, ValueDate = new DateTime(2026, 1, 2) },
});
var accounts = BuildService().GetAccountViewList();
accounts.Single(a => a.Id == "cash").Balance.ShouldBe(920m); // 1000 - 80
accounts.Single(a => a.Id == "groc").Balance.ShouldBe(80m);
}
[Fact]
public void ConvertBalanceToChfWhenAccountUsesForeignCurrency()
{
var usd = Account("usd", 1050, "USD", startBalance: 100m);
accountRepo.GetAccountList().Returns(new List<Account> { usd });
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
// 1 USD = 0.90 CHF
fxService.ConvertCurrency(Arg.Any<decimal>(), "USD", "CHF", Arg.Any<string>())
.Returns(ci => ci.ArgAt<decimal>(0) * 0.9m);
var account = BuildService().GetAccountViewList().Single();
account.Balance.ShouldBe(100m); // account currency
account.BalanceCHF.ShouldBe(90m); // FX-converted
account.StartBalanceCHF.ShouldBe(90m);
}
// --- Bank-transaction auto-matching --------------------------------------
[Fact]
public void MatchBankTransactionByBookingRuleWhenLookupTextIsContained()
{
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var utilities = Account("util", 4200, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { checking, utilities });
bookingRuleRepo.GetBookingRuleList().Returns(new List<BookingRule>
{
new() { Id = "r1", LookupText = "electric", AccountId = "util", BookingText = "Strom" }
});
bankTransactionRepo.GetBankTransactionList().Returns(new List<BankTransaction>
{
new() { Id = "b1", Text = "Monthly Electric Bill", Value = -85m, BankAccountNumber = "CH01",
ValueDate = new DateTime(2026, 3, 5), BookingDate = new DateTime(2026, 3, 5) }
});
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
var matched = BuildService().MatchOpenBankTransactions().Single();
matched.Text.ShouldBe("Strom");
// negative value => bank account is origin; rule fills the target
matched.OriginAccountId.ShouldBe("chk");
matched.TargetAccountId.ShouldBe("util");
}
[Fact]
public void PreferBookingRuleWithMatchingValueWhenMultipleRulesMatchText()
{
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var rent = Account("rent", 4100, "CHF");
var groceries = Account("groc", 4200, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { checking, rent, groceries });
bookingRuleRepo.GetBookingRuleList().Returns(new List<BookingRule>
{
new() { Id = "r1", LookupText = "coop", LookupValue = 0m, AccountId = "groc", BookingText = "Groceries" },
new() { Id = "r2", LookupText = "coop", LookupValue = 1200m, AccountId = "rent", BookingText = "Rent via Coop" }
});
bankTransactionRepo.GetBankTransactionList().Returns(new List<BankTransaction>
{
new() { Id = "b1", Text = "COOP payment", Value = -1200m, BankAccountNumber = "CH01",
ValueDate = new DateTime(2026, 3, 1), BookingDate = new DateTime(2026, 3, 1) }
});
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
var matched = BuildService().MatchOpenBankTransactions().Single();
matched.Text.ShouldBe("Rent via Coop");
matched.TargetAccountId.ShouldBe("rent");
}
[Fact]
public void FallBackToSameAccountsLastMonthWhenNoBookingRuleMatches()
{
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var salary = Account("sal", 3000, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { checking, salary });
bookingRuleRepo.GetBookingRuleList().Returns(new List<BookingRule>());
bankTransactionRepo.GetBankTransactionList().Returns(new List<BankTransaction>
{
new() { Id = "b2", Text = "Salary Feb", Value = 5000m, BankAccountNumber = "CH01",
ValueDate = new DateTime(2026, 2, 25), BookingDate = new DateTime(2026, 2, 25) }
});
// A prior-month booking with same accounts + value should be picked up
transactionRepo.GetTransactionList().Returns(new List<Transaction>
{
new()
{
Id = "t1",
OriginAccountId = "sal",
TargetAccountId = "chk",
Value = 5000m,
Text = "Monatslohn",
ValueDate = new DateTime(2026, 1, 25),
BookingDate = new DateTime(2026, 1, 25)
}
});
var matched = BuildService().MatchOpenBankTransactions().Single();
matched.Text.ShouldBe("Monatslohn");
}
[Fact]
public void ExcludeIgnoredAndAlreadyBookedWhenGettingOpenBankTransactions()
{
bankTransactionRepo.GetBankTransactionList().Returns(new List<BankTransaction>
{
new() { Id = "open", Text = "still open", Ignore = false },
new() { Id = "ignored", Text = "ignore me", Ignore = true },
new() { Id = "booked", Text = "already booked", Ignore = false }
});
transactionRepo.GetTransactionList().Returns(new List<Transaction>
{
new() { Id = "t1", BankTransactionId = "booked", OriginAccountId = "a", TargetAccountId = "b", Value = 10 }
});
var open = BuildService().GetOpenBankTransactionList();
open.Count.ShouldBe(1);
open[0].Id.ShouldBe("open");
}
// --- Helpers -------------------------------------------------------------
private static Account Account(string id, int number, string currency, decimal startBalance = 0m, string? bankAccountNumber = null)
=> new()
{
Id = id,
Number = number,
Name = id,
Currency = currency,
StartBalance = startBalance,
BankAccountNumber = bankAccountNumber
};
}

View file

@ -1,4 +1,5 @@
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;
@ -22,8 +23,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 == 3).Sum(m => m.Balance)); profit = Math.Abs(accounts.Where(m => m.Class == ClassIds.Income).Sum(m => m.Balance));
loss = Math.Abs(accounts.Where(m => m.Class == 4).Sum(m => m.Balance)); loss = Math.Abs(accounts.Where(m => m.Class == ClassIds.Expenses).Sum(m => m.Balance));
win = profit-loss; win = profit-loss;
(header, footer) = Report.GetViewDataTitleAndFooter("Erfolgsrechnung", settingsService); (header, footer) = Report.GetViewDataTitleAndFooter("Erfolgsrechnung", settingsService);

View file

@ -1,5 +1,4 @@
using System.Globalization; using Schaad.Accounting.Interfaces;
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;
@ -9,21 +8,20 @@ namespace Schaad.Accounting.UI
{ {
public static class Extensions public static class Extensions
{ {
public static string ToFormattedString(this decimal value) public static IServiceCollection AddAccounting(this IServiceCollection services)
{
var culture = new CultureInfo("de-CH");
culture.NumberFormat.NumberGroupSeparator = "'";
return value.ToString("#,0.00", culture);
}
public static IServiceCollection AddRepositories(this IServiceCollection services)
{ {
// Singleton because MyHeader triggers a full page reload (forceLoad: true) after
// changing year/mandator; a Scoped instance would be recreated on the new circuit
// and lose the selection. TODO: persist selection to a cookie/query string so this
// can safely become Scoped (see IMPROVEMENT_PLAN.md).
services.AddSingleton<ISettingsService, SettingsService>(); services.AddSingleton<ISettingsService, SettingsService>();
services.AddScoped<RepositoryCache>();
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<ISplitPredefinitonRepository, SplitPredefinitonRepository>(); services.AddScoped<ISplitPredefinitionRepository, SplitPredefinitionRepository>();
services.AddScoped<ISubclassRepository, SubclassRepository>(); services.AddScoped<ISubclassRepository, SubclassRepository>();
services.AddScoped<ITransactionRepository, TransactionRepository>(); services.AddScoped<ITransactionRepository, TransactionRepository>();
@ -37,18 +35,5 @@ 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;
}
} }
} }

View file

@ -1,4 +1,5 @@
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;
@ -23,7 +24,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.AddRepositories().AddServices(); builder.Services.AddAccounting();
var app = builder.Build(); var app = builder.Build();
@ -38,15 +39,19 @@ 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();
} }
} }