BaseRepository takes an ILogger via its constructor and logs the failing file path and exception in Save's catch block before rethrowing. Every concrete repository takes ILogger<ThisRepo> and passes it up so failures are categorised by repository type. Pulls Microsoft.Extensions.Logging.Abstractions into the Db project. TransactionRepositoryTestShould and AccountRepositoryTestShould pass NullLogger<T>.Instance. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
12 KiB
12 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. - PR H — Phase 2 item 18 (first slice): add
ILogger<FileService>toFileService.ImportAccountStatementFileso bank-statement imports emitInformationfor the file being processed and each account's import count,Warningwhen an account is skipped because it belongs to a different mandator, andErrorwhen the vendor parser reports a failure. Rest of the logging (BaseRepository save failures, MatchOpenBankTransactions summary) tracked as a follow-up because it requires threading loggers through all seven repositories. - PR I — Phase 2 item 12: move the service interfaces (
IViewService,IFileService,IChartService) fromSchaad.Accounting.CommonintoSchaad.Accounting.Services/Interfaces/. Namespaces are unchanged (Schaad.Accounting.Interfaces), so no consumer needs ausingupdate. Drops theSchaad.Finance.Api<Reference>fromCommon.csproj— Common is now vendor-free and matches its documented role as the "shared models, DTOs, interfaces" layer. - PR J — Phase 2 item 18 (second slice): thread
ILogger<T>throughBaseRepositoryand every concrete repository soBaseRepository.Save's catch block logs the failing file path and exception before rethrowing (previously the exception's origin was silently swallowed and only the stack trace at the callsite survived). PullsMicrosoft.Extensions.Logging.Abstractionsinto the Db project.TransactionRepositoryTestShouldandAccountRepositoryTestShoulduseNullLogger<T>.Instance. Remaining item 18 work (MatchOpenBankTransactions match-count summary inViewService) still open.