- 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>
9.2 KiB
9.2 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 items 9 + 11 + 13 + 15 + 16 + 17.
- PR D — Phase 3 (async).
- PR E — Phase 4 (tests) — done alongside PR C to lock in behavior.