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>
11 KiB
11 KiB
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.
SettingsServiceis registered asSingleton(Schaad.Accounting.UI/Extensions.cs:21) but holds mutableyear/mandatorfields (Schaad.Accounting.Services/SettingsService.cs:13-14). Any user switching year/mandator changes it for all connected Blazor Server users. TrySetYearrollback is broken.SettingsService.cs:33capturesvar oldYear = year;— that's the parameter, notthis.year. The "rollback" restores the same value that just failed.ChartServicemutates a shared service.GetAccountExpensesPerMonth(accountId, year)(ChartService.cs:87) callssettingsService.TrySetYear(year)thenSetYear(DateTime.Now.Year)to reload prior-year data. Global mutation on a singleton — guaranteed to race in production.DummyFxServicehas a typo.DummyFxService.cs:13—currencies.Contains(fromCurrency) && currencies.Contains(fromCurrency)(both sides checkfromCurrency). Also it silently returns the raw amount for supported currencies, so all "CHF conversions" are no-ops.TransactionRepository.GetTransactionmutates the entity.TransactionRepository.cs:67-81dividesValuebyFxRateon read, andSaveTransactionmultiplies byFxRateon write. Also silently NREs when the id is not found (line 73 dereferencestransaction).ViewService.GetTransactionViewList(accountId)mutates transactions.ViewService.cs:172flipst.Value *= -1on the loaded transaction.FileService.GetTransactionListCsvdoes the same (FileService.cs:137). These are the shared objects returned by the repository — later reads see wrong signs.- DI double-registration.
Extensions.csregistersIChartService,IViewService,IFileService, statement services, andIFxServicein bothAddRepositoriesandAddServices. AlsoAddServicesregistersPdfParsingServiceas its own key (services.AddSingleton<PdfParsingService, PdfParsingService>()) instead ofIPdfParsingService. - Magic numbers instead of
ClassIds.ProfitLossReport.razor.cs:25-26uses.Class == 3/.Class == 4. ClassIdsdoc/code mismatch.CLAUDE.mdstatesActiva=1000, Passiva=2000, …, butClassIds.csdefines them as1,2,3,4(matchesAccount.Class = Number / 1000).- Non-atomic XML writes, no locking.
BaseRepository.Savewrites directly to the target path. Interrupted writes corrupt data. Two concurrent saves interleave. No temp-file+rename, noFileSharelock. AccountRepository.EnsureFileExisitscallsSaveAccountin a loop. Each call re-loads and re-serializes the whole account list.ProfitLossReport/BalanceSheetReportcompute totals by re-summing balances thatViewServicealready 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()andGetTransactionList()many times. Every one is a fresh file read + XML deserialization. - O(N·M) lookups.
ViewService.cs:138-139—accountList.Single(...)inside aSelectover all transactions. Should be a dictionary keyed by id. - No logging.
ILoggeris not used anywhere. - No error surface. Repositories throw or return
null; UI dereferences with!. - Anemic domain model +
Copymethods.Account.Copy,Transaction.Copy,SubClass.Copyetc. are hand-rolled property copies used to merge edits into loaded entities. - Vendor coupling in the Domain project.
Schaad.Accounting.CommonreferencesSchaad.Finance.Api.dll.IChartService,IFileService, andIViewServicelive inCommonand depend onSchaad.Finance.Apitypes. - Duplicated formatting logic.
ToFormattedString(decimal)exists inExtensions.csand again inFileService.cs. - Culture setup in
Program.csruns afterMapRazorComponentsand just setsDefaultThreadCurrentCultureglobally. Should beRequestLocalizationOptionsmiddleware. - Constructors doing I/O.
AccountRepository,SubclassRepository,SplitPredefinitonRepository,BookingRuleRepositorycallEnsureFileExisitsin the constructor. - Typos leak into public API.
EnsureFileExisits,SplitPredefinitonRepository. - Dead / redundant code.
AccountRepository.EnsureFileExisitsusesnewto hide the base method; itsfile.IndexOf("Accounts") > -1guard is redundant.Home.razor.cs:50uses aloadedflag thoughOnInitializedAsyncalready runs once per instance. Commented-out//var subclasses = subclassRepository.GetSubClassList();inChartService. - 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 onnet9.0. Common.csprojhas<Folder Include="Interfaces\Extensions\" />for a folder that does not exist.ClassIdsshould bestatic 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)
ChangeDeferred.ISettingsServiceregistration fromSingletontoScoped.MyHeader.YearChangedusesNavigateTo(..., 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.- Fix
TrySetYear(capturethis.yearbefore overwriting). Defer theChartServiceprior-year-loading pattern to Phase 3. - Fix
DummyFxServicetypo (or delete the class and replace with a realIFxServiceimplementation fromSchaad.Finance.Api). - Remove mutation-on-read in
TransactionRepository.GetTransaction,ViewService.GetTransactionViewList(accountId), andFileService.GetTransactionListCsv. - Add a not-found guard to
TransactionRepository.GetTransaction. - De-duplicate the DI registrations. One
AddAccounting()extension called once inProgram.cs. - Replace
.Class == 3/4magic numbers withClassIds.*. Update CLAUDE.md's incorrect ClassIds section. - Make XML writes atomic: write to
foo.xml.tmpthenFile.Move(..., overwrite: true). Wrap Load/Save in a per-fileSemaphoreSlim(or a simplelock).
Phase 2 — Structural cleanup
- Introduce a per-request unit of work / cache. A scoped
IAccountingContextthat loads each XML file at most once per request and holds the deserialized lists. - Replace
Copy(target)methods with a single merge-in-place pattern (orrecord with). - Extract shared formatting to a single
Formattinghelper. - Move
IFileService,IChartService,IViewServiceout ofCommon(they depend onSchaad.Finance.Api). Common should have no vendor dependency. - Fix typos (
EnsureFileExists,SplitPredefinitionRepository). - Move file existence bootstrapping out of constructors into a startup step (
IHostedServiceor lazy first-use). - Fix
AccountRepository.EnsureFileExiststo compute the start-balance updates in memory and save once. - Remove legacy NuGet packages. Remove the stale
Interfaces\Extensions\folder entry. - Set culture via
RequestLocalizationOptionsmiddleware. - Add
ILogger<T>to services and repositories. 18b. Persist selected year and mandator across page reloads (cookie, query string, orProtectedLocalStorage). Prerequisite for makingISettingsServiceScoped(Phase 1 item 1, deferred).
Phase 3 — Async & performance
- Convert repository interfaces to async.
- Cache the current view's data behind the scoped unit-of-work; invalidate on save.
- Precompute
accountsByIdandsubclassNameByNumberdictionaries once per request. - Clean up
ChartServiceprior-year loading pattern: introduce an explicit "load year data" helper instead of mutatingISettingsService.
Phase 4 — Testability & safety net
- Add a
Schaad.Accounting.TestsxUnit project. - First tests:
ViewService.GetBalanceView,MatchBankTransactionByBookingRule/SameAccountsLastMonth,TransactionRepositoryFX round-trip,FileService.GetTransactionListCsv. - Abstract the XML store (
IEntityStore<T>) so tests use an in-memory store.
Phase 5 — Nice-to-haves
- Replace hand-written XML models with
recordtypes + source-generated serializers. - Inject Fixer.io key via
IOptions<FxSettings>instead of threading through method params. - Prune unused Fluent UI packages.
- 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 mutatingISettingsServiceon the singleton to hop years, with the year parameter always hardcoded toDateTime.Now.Year, so the Spendings-over-time chart discarded the user's header year selection. - PR F — Phase 3 item 21: precomputed dictionaries in
ViewServiceto 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.TestsxUnit + NSubstitute + Shouldly project. Adopts thexxxTestShould.DoThisWhenThatnaming convention with Shouldly assertions (no xUnitAssert.*). CoversViewService,TransactionRepository,Formatting,RepositoryCache,AccountRepository,ChartService, andFileService.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.