IViewService, IFileService, and IChartService move from Schaad.Accounting.Common/Interfaces/ to Schaad.Accounting.Services/Interfaces/. Namespaces are unchanged, so no consumer needs a using update. Common's vendor <Reference Include="Schaad.Finance.Api"> can be dropped, matching Common's documented role as the shared models/DTOs/interfaces layer. 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.