Compare commits

...

43 commits

Author SHA1 Message Date
0fdecc9e67 UI Chart improvements 2026-08-08 15:04:38 +02:00
4cc0888627 Fix booking-text totals tests to match intended target-only filter
These tests queried an Activa account (checking) as accountId, but
the totals chart only ever runs for Expense-class accounts, which
are always the target side of a spend booking. With a realistic
expense-account setup, the existing TargetAccountId filter behaves
correctly: it excludes bookings where the account is the origin
(a negative value can't be shown as a pie slice) rather than netting
them against the target-side booking.
2026-08-07 18:29:20 +02:00
5d6d49ed8b Group booking-text charts by first word instead of full text
Bookings like "Narbonne Car" and "Narbonne Food" should roll up into
one series/slice instead of staying separate, so grouping now uses
the text up to the first space via TransactionDataset.GetTextGroup().
2026-08-07 18:23:22 +02:00
7f284b1032 Add booking-text charts to the transactions page
Adds two charts to the per-account transactions view: a stacked bar
chart of monthly totals and a pie chart of overall totals, both
grouped by booking text, so recurring bookings (rent, subscriptions,
etc.) are visible at a glance. Chart components are keyed on the
account id so switching accounts remounts them instead of reusing a
stale Plotly instance.
2026-07-22 14:39:51 +02:00
a19bdf4558 Fix SpendingsOverTime chart: apply FX conversion and use subclass names
ChartService summed raw transaction Value instead of converting via
GetValue(account.IsFxAccount), so FX expense accounts charted in their
foreign currency labeled as CHF. Subclass series also took their Name
from an arbitrary account in the group instead of the actual subclass
name.
2026-07-22 11:03:19 +02:00
90660177f2 PR U-13: title + aria-label on icon-only FluentButtons
Add title (tooltip) + aria-label (screen reader) to the 13 icon-only
buttons across Accounts, BookingRules, BookingTexts, Classes,
Transactions, PendingBookings, and TransactionSplitDialog. Buttons
that already have visible text are unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-07 08:21:50 +02:00
1c5227ab9a PR U-12: report primitives + print CSS
Extract ReportHeader, ReportDivider, ReportSectionHeader, and
ReportFooter under Components/Pages/Reports/ and adopt them across
BalanceReport, BalanceSheetReport, ProfitLossReport, and DetailReport.
Row shapes are unchanged; the primitives just fold the divider hr
rows (30+ occurrences), section headers (8), and header/footer (4
each). ProfitLossReport / DetailReport also drop leftover
`m.Class == 3/4` magic numbers for ClassIds. Print CSS gains
`table.report tr { page-break-inside: avoid; }` so section headers
don't land at the bottom of a page alone. Net -88 lines across the
four report razor files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-07 08:15:00 +02:00
acc8b14c06 Mark U-11 as won't-do after second failed attempt
Second attempt with all three suspected fixes applied
(materialised Items field, static readonly identity delegates,
Autocomplete downgraded from Both to List) still froze the UI on
Home. FluentCombobox in Fluent UI Blazor 4.11.8 is not viable
inside a per-row @foreach on a Blazor Server page independent of
parameter stability. Native <input list> stays. Record both attempts
and the fallback options for anyone who wants to try again later.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-07 08:09:02 +02:00
0432a2db75 Note U-11 revert in the UI plan
Record the reason U-11 was reverted (SignalR flood from per-row
FluentCombobox with fresh Items IEnumerable + inline delegate
parameters on every render) and what a retry needs (materialised
sortedBookingTexts field, static Func<string, string> for OptionText/
OptionValue, browser verification with a real pending-bookings count).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-06 17:42:34 +02:00
2522274928 Revert "PR U-11: FluentCombobox for booking-text autocomplete"
This reverts commit ceef2c162f.
2026-07-06 17:41:58 +02:00
ceef2c162f PR U-11: FluentCombobox for booking-text autocomplete
Replace the native <input list="texts"> + <datalist> pattern in
PendingBookings and TransactionSplitDialog with FluentCombobox +
Autocomplete=Both so the booking-text autocomplete matches the rest
of the Fluent-driven forms. Sort the suggestions once via a
sortedBookingTexts computed getter per codebehind rather than
sorting inside each row's foreach.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-06 17:01:30 +02:00
97bf85d6a8 PR U-10: split Home into StatementImport and PendingBookings
Home shrinks from ~200-line codebehind + 100-line markup to a
10-line orchestrator holding a @ref to <PendingBookings/> and
wiring <StatementImport OnImported="RefreshPendingAsync"/> to it.

Also renames IsCanceled -> cancelRequested and clears the Files
dictionary after each import batch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-06 16:29:51 +02:00
0c48539ce6 PR U-9: FluentSearch filter on grid pages
Accounts, BookingRules, BookingTexts, BankTransactions each get an
<FluentSearch> above the grid, backed by a computed Filtered
IQueryable over an in-memory source list. FluentSearch is Immediate,
so filtering happens on each keystroke; data sizes are small enough
for per-render LINQ.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 10:10:43 +02:00
766295a119 PR U-8: format the Transactions delete-confirmation amount
Route transaction.Value through ToFormattedString() so the confirmation
reads "Betrag 1'234.57" instead of "Betrag 1234.567", and fix the
"Transaction" → "Transaktion" typo. One-file change — the other four
CRUD pages don't put values into confirmations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 10:06:44 +02:00
69d3122243 PR U-7: success toasts after mutations
Add IToastService.ShowToast(ToastIntent.Success, ...) after each
successful save/delete across Accounts, BookingRules, BookingTexts,
Classes, and Transactions. Toasts fire after the list re-fetch so
the confirmation is visible even if the edited item paginated out of
view.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 09:51:38 +02:00
562338d0c4 PR U-6a: MainLayout wraps @Body in an ErrorBoundary
Add a Blazor built-in ErrorBoundary around @Body with a friendly
FluentMessageBar + retry / home buttons. Also hook
NavigationManager.LocationChanged so the boundary resets on any nav
(otherwise the error state persists across NavigateTo and "Zur
Startseite" still shows the failure banner).

U-6 split into U-6a (this) and U-6b (per-handler try/catch to route
expected exceptions to IMessageService — deferred).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 09:47:14 +02:00
58ca05f83e PR U-5: shared Loading component
Add Components/Loading.razor and swap the ad-hoc `<p>Lädt…</p>` guard
visuals for it across the four reports, three edit dialogs, and three
chart pages (the last three now show the placeholder instead of a
blank page while waiting for view data). Kept as a pure visual so
each call site changes from `<p>Lädt…</p>` to `<Loading/>` with no
other churn.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 09:44:23 +02:00
577d63c0fa PR U-4a: Home refreshes in place instead of forceLoad
SplitBankTransactionAsync ended with NavigateTo(forceLoad: true), which
tore down the SignalR circuit and re-ran every repo constructor just
to refresh the pending-bookings list. Extract data loading into a
LoadDataAsync helper called from OnInitializedAsync, SplitBank
TransactionAsync, and OnCompletedAsync — the page refreshes in-circuit,
scoped caches survive, and the NavigationManager injection can go.

Also flags the plan: U-4 is split into U-4a (this) and U-4b (persist
settings + drop MyHeader's forceLoad, touching ~15 pages).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 09:39:03 +02:00
93bfcae2a0 PR U-3: validate every row in TransactionSplitDialog
The single EditContext only wrapped the first split row; subsequent
rows added by AddTransactionAsync silently bypassed
DataAnnotationsValidator. Replace editContext.Validate() with an
explicit ValidateAllRows() loop that checks Text, TargetAccountId, and
Value != 0 for every row, and render the collected messages under the
existing FluentValidationSummary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 08:36:21 +02:00
70f9ce0d58 PR U-2: guard TransactionDialog.SaveAsync against a cleared date
Guard SaveAsync against a null SelectedValue (previously
SelectedValue!.Value threw InvalidOperationException) and disable
the Save button while the date is empty so the user gets a visible
cue.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 08:32:34 +02:00
60f01d437c PR U-1: AccountSelector reacts to Accounts arriving late
Move the SelectedAccount = Accounts.FirstOrDefault(...) lookup from
OnInitialized to OnParametersSet so the preselection tracks the
Accounts list arriving asynchronously (every caller now passes []
first and hydrates after an await). Idempotent — the OnSelectedChanged
callback path returns the same AccountId back and the parent re-render
lookup finds the same account.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 19:17:41 +02:00
e6efdb5f3a Add UI improvement plan
UI-focused analysis complementing IMPROVEMENT_PLAN.md: 4 real bugs,
first-render null-guard inconsistency, Home page overload, report /
list-page visual-language split, missing error boundaries, missing
mutation feedback, missing search on grid pages, and a handful of
minor rough edges. Phased into 7 phases (U-1 .. U-12 plus
nice-to-haves) sequenced by value / risk.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 19:16:47 +02:00
d64b3cd876 Add MCP server design doc
Design for a read-only MCP server that exposes the accounting data
to Claude (or another MCP client) via stdio: project layout, DI
wiring, year/mandator scope handling, tool catalog with input/output
schemas, packaging + Claude Desktop config, and three privacy modes
(Full / Aggregate / Local) covering what actually leaves the machine
when a hosted-model client relays tool results to its model provider.

Not implemented; PR sequencing sketched at the end of the doc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 18:46:23 +02:00
f9084dc074 PR P: full async I/O top to bottom (item 19)
Every repository, service, Blazor page/dialog, and test now uses
async/await. Single atomic diff; the codebase does not compile in
intermediate states.

- BaseRepository: LoadListAsync/LoadAsync/SaveAsync return Task<T>;
  per-file locks use SemaphoreSlim so waiters can await; Save
  serialises to a MemoryStream sync (XmlSerializer has no async
  form), then File.WriteAllBytesAsync + sync File.Move.
- RepositoryCache.GetOrLoadAsync takes a Func<Task<List<T>>>.
- All 7 repository interfaces + implementations async.
- All service interfaces + implementations async (except vendor
  IFxService and stateless IFxConverter / SettingsService).
- Every Blazor OnInitializedAsync switches to await base.
- Test suite fully async, 42 tests pass.

AccountRepository.EnsureAccountsFile keeps two .GetAwaiter().GetResult()
bridges because it runs from the constructor.

Null-render guard follow-up (folded in):
Blazor now renders the component once with fields at their initial
values while OnInitializedAsync awaits — so fields declared `= null!`
are actually null on that first render and things like
`accounts.GroupBy(...)` throw ArgumentNullException. Fixed across
Transactions, BalanceReport, BalanceSheetReport, ProfitLossReport,
DetailReport, Assets, Spendings, SpendingsOverTime, TransactionDialog,
TransactionSplitDialog, and BookingRuleDialog:
- Collection fields initialise to [] so first-render loops are empty.
- Single-object data fields become nullable; the razor wraps
  consumption in `@if (field is null) { <p>Lädt…</p> return; }`.
- <PlotlyChart> guarded behind a null check on config/layout/data so
  Plotly.Blazor's @bind doesn't see nulls.
- Header/footer strings initialise to "" instead of null!.

Architectural hygiene on a single-user local Blazor Server app: the
observed win is one File.ReadAllBytesAsync and one
File.WriteAllBytesAsync per Load/Save, and after PR D each file is
loaded at most once per SignalR circuit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 18:30:44 +02:00
e7ff097397 PR O: convert BalanceDataset and BalanceSheetDataset to records
Both were constructor-initialised value carriers with read-only
consumers. Positional record syntax replaces the class + property +
ctor boilerplate; ViewService callers updated to PascalCase named
args to match the record-generated parameter names.

Rest of item 26 descoped: XmlSerializer needs mutable public
setters (value-equality on mutable data is a footgun) and has no
source generator without changing file format. See
IMPROVEMENT_PLAN.md for the full rationale.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 17:56:29 +02:00
786fafefa5 PR N: polish pass — ClassIds static, drop loaded flag, mandator from config
Three long-noted rough edges from the original analysis, each a
one-line touch:

- ClassIds becomes static class (was instantiable).
- Home.razor.cs drops the `loaded` bool guard (Blazor already runs
  OnInitializedAsync exactly once per component instance).
- Move the "Claudio Schaad" mandator default out of SettingsService
  into SettingsDataset.DefaultMandator, plumbed through
  appsettings.Development.json.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 22:07:08 +02:00
1f1575b0af PR M: log MatchOpenBankTransactions summary; drop dead injection
Add ILogger<ViewService> and log the match-count summary at the end
of MatchOpenBankTransactions: "Matched {Matched} of {Total} open bank
transactions". Completes item 18.

Also drop the ISettingsService [Inject] from Home.razor.cs that was
declared but never referenced.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 22:04:12 +02:00
3ffe4578c2 PR L: drop Copy(target) methods in favour of index-based replace
Replace the hand-rolled model.Copy(target) pattern in every SaveXxx
with FindIndex -> in-place replace (or Add on new). Move the two
hidden defaults out of the deleted Copy bodies: Currency = "CHF"
default now lives in AccountRepository.SaveAccount; BookingDate =
ValueDate default now lives in TransactionRepository.SaveTransaction.

Delete Copy from Account, BookingRule, BookingText, SubClass, and
SplitPredefinition. Keep Transaction.Clone() (renamed from Copy, and
now includes RelatedParty) for the defensive copy in GetTransaction
and ViewService.WithDisplaySign.

Fixes a pre-existing bug where Transaction.Copy silently dropped
RelatedParty on every update save.

42 tests total, all passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 21:57:17 +02:00
857b312f9f PR K: hide IFxService and FixerIo key behind IFxConverter
New IFxConverter.ConvertToChf(amount, fromCurrency) and FxConverter
wrap the vendor IFxService and the FixerIo API key so callers stop
threading the key through every conversion. ViewService drops
IFxService and ISettingsService from its constructor and takes
IFxConverter instead; GetAccountViewList and GetBalanceSheetView no
longer read settingsService.GetSettings() per method.

FxConverterTestShould locks in the target-currency + API-key routing.
40 tests total, all passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 21:51:44 +02:00
3a770af016 PR J: log repository save failures via ILogger
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>
2026-07-02 21:47:55 +02:00
3677b5cd6e PR I: move service interfaces out of Common
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>
2026-07-02 21:44:03 +02:00
7287dbd440 PR H: log bank-statement imports via ILogger<FileService>
FileService.ImportAccountStatementFile now emits Information on the
file being processed and on each account's import count, Warning on
mandator-mismatch skips, and Error on vendor-parser failures. UI-facing
MessageDataset behaviour is unchanged.

Pulls Microsoft.Extensions.Logging.Abstractions into the Services
project; FileServiceTestShould uses NullLogger<FileService>.Instance.

Rest of item 18 (repository logging, MatchOpenBankTransactions
summary) tracked as a follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 21:30:41 +02:00
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
Claudio Schaad
a16aedcfd8
Display transactions and balances in account currency
- Transaction amounts, totals, and sidebar badges in the Transactions page now show in the account's native currency (EUR, USD, etc.) instead of always CHF
- Fixed FX conversion in SaveTransaction/GetTransaction to fire when either the origin or target account is a foreign-currency account, not only the origin side

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 16:08:01 +02:00
Claudio Schaad
2c91ac7082
Add fx rate for transaction in UI #4
Add fx rate for transaction in UI
2025-09-27 21:32:57 +02:00
2b1eb0dbb7 Add fx rate for transaction in UI 2025-09-27 21:32:10 +02:00
Claudio Schaad
37d568cbc2
Merge pull request #3
* Make line readonly after save

* Cleanup CSS

* Reorder columns
2025-06-28 11:26:34 +02:00
121 changed files with 4269 additions and 1694 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

92
CLAUDE.md Normal file
View file

@ -0,0 +1,92 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Build
dotnet build
# Run (dev server at http://localhost:5225)
cd Schaad.Accounting.UI && dotnet run
# Test
dotnet test
# Publish
dotnet publish -c Release
```
Tests live in `Schaad.Accounting.Tests` (xUnit + NSubstitute + Shouldly). There is no lint command.
## Architecture
**AccountingNext** is a Swiss personal accounting app built with ASP.NET Core 9 + Blazor (interactive server-side rendering) and Microsoft Fluent UI. Data is stored as XML files on disk — no database.
### Projects
| Project | Role |
|---|---|
| `Schaad.Accounting.UI` | Blazor web app — pages, dialogs, layout |
| `Schaad.Accounting.Services` | Business logic — balances, FX conversion, file import, charts |
| `Schaad.Accounting.Db` | Repository layer — XML serialization/deserialization |
| `Schaad.Accounting.Common` | Shared models, DTOs, interfaces |
### Data flow
```
Blazor Pages/Dialogs (UI)
→ Services (ViewService, FileService, ChartService)
→ Repositories (AccountRepository, TransactionRepository, …)
→ XML files: {DataPath}/{year}/{mandator}/Data/*.xml
```
Data is multi-year and multi-mandator. Each combination has its own directory under `DataPath` (configured in `appsettings.Development.json`). When a new year is opened, the previous year's data is copied as the starting point.
### Key services
- **ViewService** — aggregates account balances, applies FX rates, builds datasets for the UI
- **FileService** — imports bank statements (MT940, CAMT053 via external `Schaad.Finance.dll`), parses PDFs, manages backups
- **ChartService** — produces data for Plotly charts (assets, spendings over time)
- **SettingsService** — manages the active year, mandator, and file paths
### UI structure
Pages live in `Schaad.Accounting.UI/Components/Pages/`. Each page typically has a companion `Dialogs/` subfolder with Fluent UI dialog components for CRUD operations. The app is hardcoded to the `de-CH` culture.
### Key external dependencies
- `Microsoft.FluentUI.AspNetCore.Components` — UI components (FluentDataGrid, FluentDialog, etc.)
- `Plotly.Blazor` — charts
- `FreeSpire.PDF` — PDF parsing for bank statement imports
- `Schaad.Finance.dll` / `Schaad.Finance.Api.dll` — proprietary DLLs for MT940/CAMT053 parsing and FX rate lookups (FixerIo API key in `appsettings.Development.json`)
### Domain constants
`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.

114
IMPROVEMENT_PLAN.md Normal file
View file

@ -0,0 +1,114 @@
# 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)** — landed as PR P after being explicitly requested. See PR P entry below.
- **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.
- **PR H** — Phase 2 item 18 (first slice): add `ILogger<FileService>` to `FileService.ImportAccountStatementFile` so bank-statement imports emit `Information` for the file being processed and each account's import count, `Warning` when an account is skipped because it belongs to a different mandator, and `Error` when 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`) from `Schaad.Accounting.Common` into `Schaad.Accounting.Services/Interfaces/`. Namespaces are unchanged (`Schaad.Accounting.Interfaces`), so no consumer needs a `using` update. Drops the `Schaad.Finance.Api` `<Reference>` from `Common.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>` through `BaseRepository` and every concrete repository so `BaseRepository.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). Pulls `Microsoft.Extensions.Logging.Abstractions` into the Db project. `TransactionRepositoryTestShould` and `AccountRepositoryTestShould` use `NullLogger<T>.Instance`. Remaining item 18 work (MatchOpenBankTransactions match-count summary in `ViewService`) still open.
- **PR K** — Phase 5 item 27: introduce `IFxConverter.ConvertToChf(amount, fromCurrency)` and its `FxConverter` implementation. The vendor `IFxService` and the FixerIo API key are now hidden inside `FxConverter`; callers stop threading the API key through every method call. `ViewService` drops both `IFxService` and `ISettingsService` from its constructor and takes `IFxConverter` instead. Simplifies `GetAccountViewList` (no more per-call `settingsService.GetSettings()` reads) and `GetBalanceSheetView`. Two-test `FxConverterTestShould` locks in the target-currency and API-key routing.
- **PR L** — Phase 2 item 10: drop the hand-rolled `Copy(target)` methods from `Account`, `BookingRule`, `BookingText`, `SubClass`, and `SplitPredefinition`; keep only a `Transaction.Clone()` for defensive-copy needs in `GetTransaction` / `ViewService.WithDisplaySign`. Every `SaveXxx` now uses `FindIndex` → in-place replace (or `Add` for new entries), moving the two "hidden" defaults (`Account.Currency = "CHF"` when unset, `Transaction.BookingDate = ValueDate` when unset) into the corresponding `SaveXxx` method where they belong. Fixes a pre-existing bug: `Transaction.Copy` never copied `RelatedParty`, so update-saves silently dropped it. Added `PreserveRelatedPartyWhenRoundTrippingTransaction` and `DefaultBookingDateToValueDateWhenBookingDateIsUnset` as regression tests. 42 tests total.
- **PR M** — Phase 2 item 18 (final slice): add `ILogger<ViewService>` and log a match-count summary from `MatchOpenBankTransactions` (`"Matched {Matched} of {Total} open bank transactions"`). Completes item 18 — statement imports (PR H), repository save failures (PR J), and match runs now all emit structured logs. Also removes a dead `ISettingsService` injection from `Home.razor.cs` (declared with `[Inject]` but never used anywhere in the file).
- **PR N** — Small polish pass covering three long-noted rough edges from the original analysis: `ClassIds` becomes a `static class` (was instantiable); `Home.razor.cs` loses the redundant `loaded` guard in `OnInitializedAsync` (Blazor already runs that lifecycle hook once per component instance); the hardcoded `mandator = "Claudio Schaad"` in `SettingsService` moves to a `DefaultMandator` field on `SettingsDataset`, plumbed through `appsettings.Development.json`.
- **PR O** — Phase 5 item 26 (partial): convert `BalanceDataset` and `BalanceSheetDataset` to positional records. Both were constructor-initialised value carriers with read-only usage. The rest of item 26 was descoped: the XML-serialised domain models (`Account`, `Transaction`, ...) need mutable public setters for `XmlSerializer`, and value-equality on mutable data is a footgun (hash changes on mutation); source-generated XML serialisers do not exist without switching file formats. `DataSerie` was already a record; `MessageDataset` has a real mutation method (`Add`) and stays a class; `AccountDataset` / `TransactionDataset` / `BookingRuleDataset` inherit from the mutable domain models and can't cleanly become records without a bigger refactor.
- **PR P** — Phase 3 item 19: full async I/O top to bottom. `BaseRepository.LoadAsync/SaveAsync/LoadListAsync` return `Task<T>`; the per-file `lock (obj)` becomes `SemaphoreSlim` so it can be `await`-ed. `Save` serialises to a `MemoryStream` synchronously (XmlSerializer has no async form) then writes bytes with `File.WriteAllBytesAsync`; `File.Move` (atomic rename) has no async counterpart in .NET 9 and stays sync. Every repository interface + implementation, every service interface + implementation (except the vendor-owned `IFxService`), every Razor page/dialog `OnInitializedAsync`, and every test-file assertion becomes `async` / `await`. 51 files touched in one atomic diff — the codebase does not compile in intermediate states. `AccountRepository.EnsureAccountsFile` keeps two `.GetAwaiter().GetResult()` bridges because it runs from the constructor (constructors can't be async). The observed async payoff is one `File.ReadAllBytesAsync` and one `File.WriteAllBytesAsync` per Load/Save; after PR D each XML file is deserialised at most once per SignalR circuit, so on this single-user local Blazor Server app this is architectural-hygiene work, not a measurable perf win.

354
MCP_DESIGN.md Normal file
View file

@ -0,0 +1,354 @@
# MCP server design
Design produced 2026-07-03 in conversation with Claude. Read alongside `IMPROVEMENT_PLAN.md`.
## Goals
- Let Claude answer questions like *"how much did I spend on groceries in Q1?"*, *"what's my current balance in CHF?"*, *"which recurring debits changed vs. last year?"*.
- **Read-only.** No `Save*`, `Delete*`, `Backup`, or `Import*` tools ever exposed.
- Reuse the async `ViewService` / repository stack already in the codebase. No parallel data layer.
- Local stdio transport. No HTTP, no auth surface. The MCP server process runs on your machine, but any field a tool returns is forwarded by the MCP client to whichever model it's paired with. See [Privacy modes](#privacy-modes) below for what that actually means in practice.
## Architecture
### Transport
- **Stdio JSON-RPC** via the official `ModelContextProtocol` .NET SDK (Microsoft org's MCP package). Console app, `Microsoft.Extensions.Hosting`-based, `[McpServerToolType]` + `[McpServerTool]` attributes.
- No custom framing/serialization — the SDK handles it.
### Project layout
```
Schaad.Accounting.Mcp/
├── Schaad.Accounting.Mcp.csproj - net9.0 console
├── Program.cs - host + DI + McpServer.CreateStdioServer
├── Context/
│ └── AccountingScope.cs - year/mandator switching (see below)
├── Dtos/
│ ├── AccountDto.cs - flat records for LLM consumption
│ ├── TransactionDto.cs
│ └── ...
└── Tools/
├── ContextTools.cs - list_mandators, list_years
├── AccountTools.cs - list_accounts, get_account
├── BalanceTools.cs - get_balance_summary, get_income_statement
├── TransactionTools.cs - get_transactions, search_transactions
└── AnalysisTools.cs - get_expenses_by_category, compare_years
```
### DI wiring
The composition root `AddAccounting()` currently lives in `Schaad.Accounting.UI/Extensions.cs`. Two options:
1. **Extract** to a new tiny `Schaad.Accounting.Composition` project that references Db + Services. UI and Mcp both reference it.
2. **Duplicate** the ~15-line registration block in `Mcp/Program.cs`.
Recommend **(1)** — with two composition roots, every subsequent DI change would otherwise need to touch both.
`Mcp/Program.cs` is then:
```csharp
var builder = Host.CreateApplicationBuilder(args);
builder.Services.Configure<SettingsDataset>(builder.Configuration.GetSection("Settings"));
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<SettingsDataset>>().Value);
builder.Services.AddAccounting();
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly();
await builder.Build().RunAsync();
```
## Handling year/mandator
`SettingsService` is `Singleton` and holds `year` + `mandator`. The UI mutates them via `MyHeader` + `forceLoad`. There is no equivalent in stdio MCP.
**Approach: every tool takes optional `year` and `mandator`; wrap execution in a scope that stashes & restores.**
```csharp
public sealed class AccountingScope : IDisposable
{
private readonly ISettingsService settings;
private readonly int oldYear;
private readonly string oldMandator;
public AccountingScope(ISettingsService settings, int? year, string? mandator)
{
this.settings = settings;
oldYear = settings.GetYear();
oldMandator = settings.GetMandator();
if (year.HasValue) settings.SetYear(year.Value);
if (mandator is not null) settings.SetMandator(mandator);
}
public void Dispose()
{
settings.SetYear(oldYear);
settings.SetMandator(oldMandator);
}
}
```
Every tool wraps its body:
```csharp
[McpServerTool, Description("...")]
public async Task<...> ListAccountsAsync(int? year = null, string? mandator = null, ...)
{
using var _ = new AccountingScope(settingsService, year, mandator);
// RepositoryCache is Scoped; resolve a fresh IServiceScope so scoped services
// don't hold references to entries from an earlier (year, mandator).
}
```
**Concurrency**: stdio MCP calls are serial (one JSON-RPC message at a time), so the Singleton mutation window is safe. If we ever move to HTTP transport with concurrent calls, this design breaks — flag that in code.
**Cache freshness**: `RepositoryCache` is `Scoped` and keyed by absolute file path (which includes `dbPath` → year+mandator). When the scope changes year/mandator, the cache keys naturally change too, so stale data is not returned. But we should still resolve a fresh `IServiceScope` per tool call:
```csharp
public class ToolExecutor
{
private readonly IServiceScopeFactory scopeFactory;
private readonly ISettingsService settings;
public async Task<T> Run<T>(int? year, string? mandator, Func<IServiceProvider, Task<T>> body)
{
using var _ = new AccountingScope(settings, year, mandator);
using var scope = scopeFactory.CreateScope();
return await body(scope.ServiceProvider);
}
}
```
Each tool class takes `ToolExecutor` and wraps its body.
## Tool catalog
Naming: `snake_case`. All optional args default sensibly. All amounts are `decimal`, CHF-normalized where a currency isn't specified. All dates are ISO 8601 (`yyyy-MM-dd`).
### `list_mandators`
> List the mandators (client accounting sets) that have data.
- **Input:** `{ year?: int }`
- **Output:** `{ mandators: string[] }`
### `list_years`
> List the years for which the given mandator has data.
- **Input:** `{ mandator?: string }`
- **Output:** `{ years: int[] }`
### `list_accounts`
> List all accounts with their current-year balance in both account currency and CHF. Use before `get_transactions` to look up account IDs.
- **Input:** `{ year?, mandator?, class?: "activa"|"passiva"|"income"|"expenses" }`
- **Output:**
```json
{
"accounts": [
{ "id": "...", "number": 1010, "name": "Checking", "currency": "CHF",
"class": "activa", "subclassName": "Umlaufvermögen",
"balance": 12345.67, "balanceChf": 12345.67 }
]
}
```
### `get_balance_summary`
> Total activa vs. total passiva, plus equity (activa − passiva). CHF.
- **Input:** `{ year?, mandator? }`
- **Output:** `{ totalActivaChf, totalPassivaChf, equityChf }`
### `get_income_statement`
> Income statement (Erfolgsrechnung) for the year: profit, loss, net, and the account list per side.
- **Input:** `{ year?, mandator? }`
- **Output:** `{ profitChf, lossChf, netChf, incomeAccounts: [...], expenseAccounts: [...] }`
### `get_transactions`
> Fetch transactions with filters. Prefer narrow filters — this is the main workhorse. Truncated at `limit` (default 200, max 1000).
- **Input:**
```json
{
"year": 2026,
"mandator": null,
"accountId": null,
"fromDate": "2026-01-01",
"toDate": "2026-03-31",
"textSearch": "coop",
"minAmount": 100,
"maxAmount": null,
"limit": 200
}
```
- **Output:**
```json
{
"transactions": [
{ "id": "...", "date": "2026-02-14", "bookingDate": "2026-02-14",
"text": "Coop City", "value": 87.30, "currency": "CHF",
"originAccountId": "chk", "originAccountName": "Checking",
"targetAccountId": "groc", "targetAccountName": "Groceries",
"relatedParty": "Coop Genossenschaft" }
],
"total": 47,
"truncated": false
}
```
Backed by `ITransactionRepository.GetTransactionListAsync()` + in-memory filter — datasets are small enough that we don't need repo-level query pushdown.
### `get_expenses_by_category`
> Expenses grouped by subclass or account. Sorted descending by CHF total.
- **Input:** `{ year?, mandator?, groupBy?: "subclass" | "account" }`
- **Output:** `{ categories: [{ name, totalChf, transactionCount }] }`
### `compare_years`
> Side-by-side comparison for equivalent categories across years. Useful for *"how did my spending change YoY"*.
- **Input:** `{ years: int[], mandator?, side: "income" | "expenses" | "both" }`
- **Output:**
```json
{
"comparison": [
{ "year": 2025, "totalIncomeChf": ..., "totalExpensesChf": ...,
"byCategory": { "Groceries": 4800.50, "Rent": 24000, ... } },
{ "year": 2026, ... }
]
}
```
## Output shape conventions
- **Flat records**: no nested navigation properties. Account name inlined next to id so the LLM doesn't need a follow-up call.
- **CHF-normalized**: any total is always in CHF. Per-transaction values keep the account currency plus the CHF equivalent when accounts differ.
- **No booleans for "successful"** — either the tool returns data or it errors. MCP has native error surfacing.
- **Cap collection sizes**: `get_transactions` truncates at `limit`; the LLM decides whether to narrow the filter.
## Read-only enforcement
- Repository / service **interfaces** still expose write methods (they're used from the UI). What's exposed is controlled by simply not authoring a `[McpServerTool]` for those verbs.
- To make this explicit, the `Tools/` folder contains **only read operations**, and typed errors (or similar) are the only way the LLM sees "no such account".
- **Do NOT expose `SetYear` / `SetMandator` as tools.** Year/mandator switching happens implicitly per-call via `AccountingScope`. Otherwise the LLM could clobber the UI's session state.
## Packaging & Claude Desktop
Build once:
```bash
dotnet publish Schaad.Accounting.Mcp/ -c Release -o D:/AccountingMcp
```
Claude Desktop `claude_desktop_config.json`:
```json
{
"mcpServers": {
"schaad-accounting": {
"command": "D:/AccountingMcp/Schaad.Accounting.Mcp.exe",
"env": {
"Settings__DataPath": "D:/Developer/AccountingData/",
"Settings__DefaultMandator": "Claudio Schaad"
}
}
}
}
```
`FixerIoApiKey` doesn't need to be set — MCP tools only ever need CHF conversions of stored CHF values, and `DummyFxService` is the current implementation.
## Security
- **Local stdio only.** No listening port. Anything running as your user can already read the XML files; MCP doesn't widen the local-attack blast radius.
- **`DataPath` only from environment / config.** Never accept `set_data_path` or similar. The LLM must not be able to point the server at arbitrary directories.
- **No write tools** — repeated deliberately because it's the single biggest risk mitigation.
- No secrets in tool outputs. The `FixerIoApiKey` is not returned by any tool.
- Data exposure to the model provider is a separate axis from local security. See [Privacy modes](#privacy-modes) below.
## Privacy modes
**The data-flow reality.** When Claude Desktop calls an MCP tool, the invocation and result are relayed through the Anthropic API so the model can reason over them. "Local stdio" only means the *server process* runs on your PC — the JSON payload it emits still crosses the network on its way to the model. Other MCP clients (Cursor, Zed, any hosted-model client) work the same way.
To make the trade-off explicit, the server supports three privacy modes via a `Mcp:Privacy` config key.
### `Full` (default)
Every tool from the catalog is registered. Tool results include per-transaction detail: text, related party, amount, dates, source/target account names. Most useful for open-ended questions, most exposing — a single `get_transactions` call can send hundreds of rows of counterparty/amount data to the model provider.
**Use when:** you're comfortable with the model provider's data-use policy on your account, and you want maximum answer quality.
### `Aggregate`
Only aggregate tools are registered:
- `list_accounts` — trimmed: name, currency, class, `balanceChf`. **No account IDs, no bank account numbers, no start balance.**
- `get_balance_summary`
- `get_income_statement` — trimmed: totals per class, no per-account rows.
- `get_expenses_by_category` — grouped by subclass name.
- `compare_years` — same as `Full`.
Withheld: `get_transactions` (entirely), per-account balance rows, related-party names, individual dates and texts.
**Use when:** you want spending/income insight without exposing individual counterparties or transaction narratives. Trades power for privacy — Claude can answer *"how did my grocery spending change YoY?"* but not *"what was that odd 850 CHF withdrawal in March?"*.
### `Local`
Same tool catalog as `Full`, but intended to be paired with a **local** MCP client — e.g. an Ollama- or LM-Studio-backed client that runs the model on your own machine. The MCP server itself doesn't and can't enforce this; the choice happens in which client you point at the server binary. Nothing crosses the network. Answer quality is lower than Claude, sometimes considerably.
**Use when:** you don't want any financial data leaving your PC at all.
### Configuring the mode
```json
{
"mcpServers": {
"schaad-accounting": {
"command": "D:/AccountingMcp/Schaad.Accounting.Mcp.exe",
"env": {
"Settings__DataPath": "D:/Developer/AccountingData/",
"Settings__DefaultMandator": "Claudio Schaad",
"Mcp__Privacy": "Aggregate"
}
}
}
}
```
`Program.cs` reads `Mcp:Privacy` at startup and decides which `[McpServerToolType]` classes to register with the SDK. Modes are enforced by *registration*, not by runtime filtering — a tool that's not registered simply doesn't exist from the model's point of view, so the model can't accidentally call it.
For belt-and-braces defence-in-depth, the sensitive-output DTOs (`TransactionDto`, per-account rows) can also live in a separate namespace whose types are only ever *constructed* by tools that are registered under `Full`; that way an accidentally shipped aggregate build won't compile against them.
## Out of scope for v1
- **Resources**: expose the chart of accounts as an MCP `Resource` so the LLM reads structure once instead of via `list_accounts` on every conversation.
- **Prompts**: prebuilt Claude prompts (*"Monatlicher Kassenbericht"*, *"Erklär mir das Q3-Wachstum"*).
- **Bank-transaction matching preview**: expose the `MatchOpenBankTransactions` output as a read-only tool so Claude can review the auto-matcher's guesses.
- **Budgets / alerts**: not modelled in the app yet, so no data to expose.
## Rough implementation size
- New project + csproj + Program.cs: ~40 LOC
- `AccountingScope` + `ToolExecutor`: ~40 LOC
- 8 tools with DTOs: ~350 LOC (mostly declarative attribute schemas + `Select` projections)
- Extract `AddAccounting()` to `Composition` project: ~30 LOC (mostly moving)
- **Total: ~450 LOC**, plus config/docs.
## Suggested PR sequencing
- **PR Q** — Extract `AddAccounting()` from `Schaad.Accounting.UI/Extensions.cs` to a new `Schaad.Accounting.Composition` project referenced by UI and Mcp. Mechanical, low risk.
- **PR R** — Add the `Schaad.Accounting.Mcp` project with `Program.cs`, `AccountingScope`, `ToolExecutor`, and the first two tools (`list_mandators`, `list_accounts`). Enough to wire Claude Desktop and iterate on the actual conversation feel before adding analytics tools.
- **PR S** — Balance + income statement tools (`get_balance_summary`, `get_income_statement`).
- **PR T** — `get_transactions` (the workhorse) with filter validation and truncation.
- **PR U** — Analytics (`get_expenses_by_category`, `compare_years`).

View file

@ -1,6 +1,6 @@
namespace Schaad.Accounting namespace Schaad.Accounting
{ {
public class ClassIds public static class ClassIds
{ {
public const int Activa = 1; public const int Activa = 1;
public const int Passiva = 2; public const int Passiva = 2;

View file

@ -0,0 +1,4 @@
namespace Schaad.Accounting.Datasets.Charts
{
public record CategoryTotal(string Name, decimal Value);
}

View file

@ -1,23 +1,10 @@
using System.Collections.Generic; using System.Collections.Generic;
namespace Schaad.Accounting.Datasets.Reports namespace Schaad.Accounting.Datasets.Reports
{ {
public class BalanceDataset public record BalanceDataset(
{ List<AccountDataset> ActivaAccountList,
public List<AccountDataset> ActivaAccountList { get; set; } List<AccountDataset> PassivaAccountList,
decimal TotalActivaCHF,
public List<AccountDataset> PassivaAccountList { get; set; } decimal TotalPassivaCHF);
public decimal TotalActivaCHF { get; set; }
public decimal TotalPassivaCHF { get; set; }
public BalanceDataset(List<AccountDataset> activaAccountList, List<AccountDataset> passivaAccountList, decimal totalActivaCHF, decimal totalPassivaCHF)
{
ActivaAccountList = activaAccountList;
PassivaAccountList = passivaAccountList;
TotalActivaCHF = totalActivaCHF;
TotalPassivaCHF = totalPassivaCHF;
}
}
} }

View file

@ -1,29 +1,12 @@
using System.Collections.Generic; using System.Collections.Generic;
namespace Schaad.Accounting.Datasets.Reports namespace Schaad.Accounting.Datasets.Reports
{ {
public class BalanceSheetDataset public record BalanceSheetDataset(
{ List<AccountDataset> ActivaAccountList,
public List<AccountDataset> ActivaAccountList { get; } List<AccountDataset> IncomeAccountList,
List<AccountDataset> ExpensesAccountList,
public List<AccountDataset> IncomeAccountList { get; } decimal ProfitCHF,
decimal LossCHF,
public List<AccountDataset> ExpensesAccountList { get; } int Year);
public decimal ProfitCHF { get; }
public decimal LossCHF { get; }
public int Year { get; }
public BalanceSheetDataset(List<AccountDataset> activaAccountList, List<AccountDataset> incomeAccountList, List<AccountDataset> expensesAccountList, decimal profitCHF, decimal lossCHF, int year)
{
ActivaAccountList = activaAccountList;
IncomeAccountList = incomeAccountList;
ExpensesAccountList = expensesAccountList;
ProfitCHF = profitCHF;
LossCHF = lossCHF;
Year = year;
}
}
} }

View file

@ -5,5 +5,10 @@
public string DataPath { get; set; } public string DataPath { get; set; }
public string FixerIoApiKey { get; set; } public string FixerIoApiKey { get; set; }
/// <summary>
/// Mandator selected when the app starts. Users can switch via the header dropdown.
/// </summary>
public string DefaultMandator { get; set; } = "Claudio Schaad";
} }
} }

View file

@ -33,5 +33,30 @@ namespace Schaad.Accounting.Datasets
else else
return Value; return Value;
} }
public decimal GetValue(bool withFxRate, string accountId)
{
if (accountId == OriginAccountId)
{
return -1 * GetValue(withFxRate);
}
return GetValue(withFxRate);
}
/// <summary>
/// First word of <see cref="Text"/>, used to group related bookings (e.g. "Narbonne Car"
/// and "Narbonne Food" both group under "Narbonne").
/// </summary>
public string GetTextGroup()
{
if (string.IsNullOrEmpty(Text))
{
return Text;
}
var spaceIndex = Text.IndexOf(' ');
return spaceIndex < 0 ? Text : Text[..spaceIndex];
}
} }
} }

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,20 +1,21 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
namespace Schaad.Accounting.Interfaces namespace Schaad.Accounting.Interfaces
{ {
public interface IAccountRepository public interface IAccountRepository
{ {
List<Account> GetAccountList(); Task<List<Account>> GetAccountListAsync();
void SaveAccount(Account account); Task SaveAccountAsync(Account account);
Account GetAccount(string id); Task<Account> GetAccountAsync(string id);
void DeleteAccount(string id); Task DeleteAccountAsync(string id);
Account GetAccountForBankAccountNumber(string bankAccountNumber); Task<Account> GetAccountForBankAccountNumberAsync(string bankAccountNumber);
void SaveBankAccountBalance(string bankAccountNumber, decimal accountBalance); Task SaveBankAccountBalanceAsync(string bankAccountNumber, decimal accountBalance);
} }
} }

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Datasets; using Schaad.Accounting.Datasets;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -6,10 +7,10 @@ namespace Schaad.Accounting.Interfaces
{ {
public interface IBankTransactionRepository public interface IBankTransactionRepository
{ {
List<BankTransaction> GetBankTransactionList(); Task<List<BankTransaction>> GetBankTransactionListAsync();
int SaveBankTransactionList(string bankAccountNumber, List<BankTransactionDataset> bankTransactions); Task<int> SaveBankTransactionListAsync(string bankAccountNumber, List<BankTransactionDataset> bankTransactions);
BankTransaction GetBankTransaction(string id); Task<BankTransaction> GetBankTransactionAsync(string id);
} }
} }

View file

@ -1,15 +1,17 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
namespace Schaad.Accounting.Interfaces namespace Schaad.Accounting.Interfaces
{ {
public interface IBookingRuleRepository public interface IBookingRuleRepository
{ {
List<BookingRule> GetBookingRuleList(); Task<List<BookingRule>> GetBookingRuleListAsync();
void SaveBookingRule(BookingRule bookingRule); Task SaveBookingRuleAsync(BookingRule bookingRule);
BookingRule GetBookingRule(string id); Task<BookingRule> GetBookingRuleAsync(string id);
void DeleteBookingRule(string id);
Task DeleteBookingRuleAsync(string id);
} }
} }

View file

@ -1,16 +1,17 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
namespace Schaad.Accounting.Interfaces namespace Schaad.Accounting.Interfaces
{ {
public interface IBookingTextRepository public interface IBookingTextRepository
{ {
List<BookingText> GetBookingTextList(); Task<List<BookingText>> GetBookingTextListAsync();
void SaveBookingText(BookingText bookingText); Task SaveBookingTextAsync(BookingText bookingText);
BookingText GetBookingText(string id); Task<BookingText> GetBookingTextAsync(string id);
void DeleteBookingText(string id); Task DeleteBookingTextAsync(string id);
} }
} }

View file

@ -1,10 +0,0 @@
using System.Collections.Generic;
using Schaad.Accounting.Datasets.Charts;
namespace Schaad.Accounting.Interfaces
{
public interface IChartService
{
IReadOnlyList<DataSerie> GetExpensesPerMonth();
}
}

View file

@ -0,0 +1,13 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Models;
namespace Schaad.Accounting.Interfaces
{
public interface ISplitPredefinitionRepository
{
Task<List<SplitPredefinition>> GetSplitPredefinitionListAsync();
Task SaveSplitPredefinitionAsync(SplitPredefinition splitPredefinition);
}
}

View file

@ -1,12 +0,0 @@
using System.Collections.Generic;
using Schaad.Accounting.Models;
namespace Schaad.Accounting.Interfaces
{
public interface ISplitPredefinitonRepository
{
List<SplitPredefinition> GetSplitPredefinitionList();
void SaveSplitPredefinition(SplitPredefinition splitPredefinition);
}
}

View file

@ -1,17 +1,18 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
namespace Schaad.Accounting.Interfaces namespace Schaad.Accounting.Interfaces
{ {
public interface ISubclassRepository public interface ISubclassRepository
{ {
List<SubClass> GetSubClassList(); Task<List<SubClass>> GetSubClassListAsync();
void SaveSubClass(SubClass subClass); Task SaveSubClassAsync(SubClass subClass);
SubClass GetSubClass(string id); Task<SubClass> GetSubClassAsync(string id);
void DeleteSubClass(string id); Task DeleteSubClassAsync(string id);
Dictionary<int, string> GetClasses(); Dictionary<int, string> GetClasses();

View file

@ -1,15 +1,17 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
namespace Schaad.Accounting.Interfaces namespace Schaad.Accounting.Interfaces
{ {
public interface ITransactionRepository public interface ITransactionRepository
{ {
List<Transaction> GetTransactionList(); Task<List<Transaction>> GetTransactionListAsync();
void SaveTransaction(Transaction transaction); Task SaveTransactionAsync(Transaction transaction);
Transaction GetTransaction(string id); Task<Transaction> GetTransactionAsync(string id);
void DeleteTransaction(string id);
Task DeleteTransactionAsync(string id);
} }
} }

View file

@ -1,31 +0,0 @@
using System.Collections.Generic;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Datasets.Reports;
using Schaad.Accounting.Models;
using Schaad.Finance.Api.Datasets;
namespace Schaad.Accounting.Interfaces
{
public interface IViewService
{
AccountDataset GetAccountView(string id);
List<AccountDataset> GetAccountViewList();
BalanceDataset GetBalanceView();
BalanceSheetDataset GetBalanceSheetView(int year);
List<TransactionDataset> GetTransactionViewList();
List<TransactionDataset> GetTransactionViewList(string accountId);
List<BookingRuleDataset> GetBookingRuleViewList();
List<BankTransaction> GetOpenBankTransactionList();
List<Transaction> MatchOpenBankTransactions();
List<Transaction> MatchCreditCardTransactions(string bankTransactionId, IReadOnlyList<CreditCardTransaction> creditCardTransactions);
}
}

View file

@ -59,19 +59,5 @@ namespace Schaad.Accounting.Models
{ {
get { return string.IsNullOrEmpty(Currency) == false && Currency != "CHF"; } get { return string.IsNullOrEmpty(Currency) == false && Currency != "CHF"; }
} }
/// <summary>
/// Makes a copy
/// </summary>
public void Copy(Account target)
{
target.LastBankBalance = LastBankBalance;
target.StartBalance = StartBalance;
target.BankAccountNumber = BankAccountNumber;
target.Currency = string.IsNullOrEmpty(Currency) ? "CHF" : Currency;
target.Id = Id;
target.Name = Name;
target.Number = Number;
}
} }
} }

View file

@ -20,18 +20,5 @@ namespace Schaad.Accounting.Models
[Display(Name = "Konto")] [Display(Name = "Konto")]
[Required] [Required]
public string AccountId { get; set; } public string AccountId { get; set; }
/// <summary>
/// Makes a copy
/// </summary>
public void Copy(BookingRule target)
{
target.LookupText = LookupText;
target.LookupValue = LookupValue;
target.BookingText = BookingText;
target.Id = Id;
target.AccountId = AccountId;
}
} }
} }

View file

@ -10,14 +10,5 @@ namespace Schaad.Accounting.Models
[Required] [Required]
[MinLength(3)] [MinLength(3)]
public string Text { get; set; } public string Text { get; set; }
/// <summary>
/// Makes a copy
/// </summary>
public void Copy(BookingText target)
{
target.Id = Id;
target.Text = Text;
}
} }
} }

View file

@ -9,17 +9,5 @@
public decimal BookingValue { get; set; } public decimal BookingValue { get; set; }
public string AccountId { get; set; } public string AccountId { get; set; }
/// <summary>
/// Makes a copy
/// </summary>
public void Copy(SplitPredefinition target)
{
target.BookingText = BookingText;
target.BookingValue = BookingValue;
target.Id = Id;
target.AccountId = AccountId;
}
} }
} }

View file

@ -14,15 +14,5 @@ namespace Schaad.Accounting.Models
[Required] [Required]
[MinLength(3)] [MinLength(3)]
public string Name { get; set; } public string Name { get; set; }
/// <summary>
/// Makes a copy
/// </summary>
public void Copy(SubClass target)
{
target.Id = Id;
target.Name = Name;
target.Number = Number;
}
} }
} }

View file

@ -2,6 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Xml.Serialization;
namespace Schaad.Accounting.Models namespace Schaad.Accounting.Models
{ {
@ -43,6 +44,9 @@ namespace Schaad.Accounting.Models
[Display(Name = "Währungskurs")] [Display(Name = "Währungskurs")]
public decimal? FxRate { get; set; } public decimal? FxRate { get; set; }
[XmlIgnore]
public bool ReadOnly {get; set;}
public Transaction() public Transaction()
{ {
} }
@ -70,20 +74,22 @@ namespace Schaad.Accounting.Models
} }
/// <summary> /// <summary>
/// Makes a copy /// Returns a shallow independent copy — used to hand out defensive copies
/// (e.g. from repository reads) without exposing the caller to later mutation.
/// </summary> /// </summary>
public void Copy(Transaction target) public Transaction Clone() => new()
{ {
target.BankTransactionId = BankTransactionId; Id = Id,
target.BankTransactionText = BankTransactionText; BankTransactionId = BankTransactionId,
target.Id = Id; BankTransactionText = BankTransactionText,
target.OriginAccountId = OriginAccountId; RelatedParty = RelatedParty,
target.TargetAccountId = TargetAccountId; OriginAccountId = OriginAccountId,
target.Text = Text; TargetAccountId = TargetAccountId,
target.Value = Value; Text = Text,
target.ValueDate = ValueDate; Value = Value,
target.BookingDate = BookingDate > DateTime.MinValue ? BookingDate : ValueDate; ValueDate = ValueDate,
target.FxRate = FxRate; BookingDate = BookingDate,
} FxRate = FxRate
};
} }
} }

View file

@ -4,17 +4,4 @@
<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>
<Reference Include="Schaad.Finance.Api">
<HintPath>..\dlls\Schaad.Finance.Api.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Folder Include="Interfaces\Extensions\" />
</ItemGroup>
</Project> </Project>

View file

@ -1,7 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -11,103 +13,97 @@ 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, ILogger<AccountRepository> logger)
: base(settingsService, cache, logger)
{ {
EnsureFileExisits(ACCOUNTS); EnsureAccountsFile();
} }
public Task<List<Account>> GetAccountListAsync() => LoadListAsync<Account>(ACCOUNTS);
/// <summary> public async Task SaveAccountAsync(Account account)
/// Load accounts
/// </summary>
public List<Account> GetAccountList()
{ {
var accounts = Load<List<Account>>(ACCOUNTS); if (string.IsNullOrEmpty(account.Currency))
return accounts ?? new List<Account>();
}
/// <summary>
/// Save an account (insert/update)
/// </summary>
public void SaveAccount(Account account)
{
var accounts = GetAccountList();
var existingAccount = accounts.FirstOrDefault(a => a.Id == account.Id);
if (existingAccount == null)
{ {
existingAccount = new Account(); account.Currency = "CHF";
accounts.Add(existingAccount);
account.Id = Guid.NewGuid().ToString();
} }
account.Copy(existingAccount);
Save(accounts, ACCOUNTS); var accounts = await GetAccountListAsync();
var idx = accounts.FindIndex(a => a.Id == account.Id);
if (idx >= 0)
{
accounts[idx] = account;
}
else
{
account.Id = Guid.NewGuid().ToString();
accounts.Add(account);
}
await SaveAsync(accounts, ACCOUNTS);
} }
/// <summary> public async Task<Account> GetAccountAsync(string id)
/// Get account
/// </summary>
public Account GetAccount(string id)
{ {
var accounts = GetAccountList(); var accounts = await GetAccountListAsync();
return accounts.FirstOrDefault(t => t.Id == id); return accounts.FirstOrDefault(t => t.Id == id);
} }
/// <summary> public async Task DeleteAccountAsync(string id)
/// Delete account
/// </summary>
public void DeleteAccount(string id)
{ {
var accounts = GetAccountList(); var accounts = await GetAccountListAsync();
accounts.RemoveAll(t => t.Id == id); accounts.RemoveAll(t => t.Id == id);
Save(accounts, ACCOUNTS); await SaveAsync(accounts, ACCOUNTS);
} }
/// <summary> public async Task<Account> GetAccountForBankAccountNumberAsync(string bankAccountNumber)
/// Get account
/// </summary>
public Account GetAccountForBankAccountNumber(string bankAccountNumber)
{ {
var accounts = GetAccountList(); var accounts = await GetAccountListAsync();
return accounts.SingleOrDefault(t => t.BankAccountNumber == bankAccountNumber); return accounts.SingleOrDefault(t => t.BankAccountNumber == bankAccountNumber);
} }
public void SaveBankAccountBalance(string bankAccountNumber, decimal accountBalance) public async Task SaveBankAccountBalanceAsync(string bankAccountNumber, decimal accountBalance)
{ {
var account = GetAccountForBankAccountNumber(bankAccountNumber); var account = await GetAccountForBankAccountNumberAsync(bankAccountNumber);
account.LastBankBalance = accountBalance; account.LastBankBalance = accountBalance;
SaveAccount(account); await SaveAccountAsync(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.
// Uses the sync GetAwaiter().GetResult() bridge because this runs from the
// constructor; the file was just copied so the underlying I/O is local.
var accounts = GetAccountListAsync().GetAwaiter().GetResult();
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)
{
SaveAsync(accounts, ACCOUNTS).GetAwaiter().GetResult();
} }
} }
} }

View file

@ -1,5 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Datasets; using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -10,26 +12,20 @@ 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, ILogger<BankTransactionRepository> logger)
: base(settingsService, cache, logger)
{ {
} }
/// <summary> public Task<List<BankTransaction>> GetBankTransactionListAsync() => LoadListAsync<BankTransaction>(BANK_TRANSACTIONS);
/// Load bank transactions
/// </summary>
public List<BankTransaction> GetBankTransactionList()
{
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)
/// </summary> /// </summary>
public int SaveBankTransactionList(string bankAccountNumber, List<BankTransactionDataset> bankTransactions) public async Task<int> SaveBankTransactionListAsync(string bankAccountNumber, List<BankTransactionDataset> bankTransactions)
{ {
var count = 0; var count = 0;
var transactions = GetBankTransactionList(); var transactions = await GetBankTransactionListAsync();
foreach (var bankTrx in bankTransactions) foreach (var bankTrx in bankTransactions)
{ {
var transaction = transactions.FirstOrDefault(t => t.Id == bankTrx.Id); var transaction = transactions.FirstOrDefault(t => t.Id == bankTrx.Id);
@ -50,16 +46,13 @@ namespace Schaad.Accounting.Repositories
count++; count++;
} }
} }
Save(transactions, BANK_TRANSACTIONS); await SaveAsync(transactions, BANK_TRANSACTIONS);
return count; return count;
} }
/// <summary> public async Task<BankTransaction> GetBankTransactionAsync(string id)
/// Get bank transaction
/// </summary>
public BankTransaction GetBankTransaction(string id)
{ {
var transactions = GetBankTransactionList(); var transactions = await GetBankTransactionListAsync();
return transactions.FirstOrDefault(t => t.Id == id); return transactions.FirstOrDefault(t => t.Id == id);
} }
} }

View file

@ -1,72 +1,153 @@
using System.IO; using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Text; using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Xml; using System.Xml;
using System.Xml.Serialization; using System.Xml.Serialization;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
namespace Schaad.Accounting.Repositories namespace Schaad.Accounting.Repositories
{ {
public abstract class BaseRepository public abstract class BaseRepository
{ {
protected readonly ISettingsService settingsService; // One semaphore per absolute file path so concurrent SaveAsync+SaveAsync and
// SaveAsync+LoadAsync are serialized and can't observe a half-written file.
private static readonly ConcurrentDictionary<string, SemaphoreSlim> FileLocks =
new(StringComparer.OrdinalIgnoreCase);
protected BaseRepository(ISettingsService settingsService) protected readonly ISettingsService settingsService;
protected readonly RepositoryCache cache;
protected readonly ILogger logger;
protected BaseRepository(ISettingsService settingsService, RepositoryCache cache, ILogger logger)
{ {
this.settingsService = settingsService; this.settingsService = settingsService;
this.cache = cache;
this.logger = logger;
} }
protected void EnsureFileExisits(string fileName) /// <summary>
/// Copies fileName from last year's directory to the current year's directory when the
/// current year doesn't have it yet. Kept synchronous because it runs from repository
/// constructors; File.Copy has no async equivalent in .NET 9 either way.
/// </summary>
protected void EnsureFileExists(string fileName)
{ {
string filePath = Path.Combine(settingsService.GetDbPath(), fileName); string filePath = Path.Combine(settingsService.GetDbPath(), fileName);
if (File.Exists(filePath) == false) var semaphore = GetLock(filePath);
semaphore.Wait();
try
{ {
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);
}
} }
} }
finally
{
semaphore.Release();
}
} }
/// <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="SaveAsync"/> invalidates the entry.
/// </summary> /// </summary>
protected void Save<T>(T obj, string fileName) protected Task<List<T>> LoadListAsync<T>(string fileName)
{ {
var filePath = Path.Combine(settingsService.GetDbPath(), fileName); var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
using (var sww = new MemoryStream()) return cache.GetOrLoadAsync(filePath, async () => await LoadAsync<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>
protected async Task SaveAsync<T>(T obj, string fileName)
{
var filePath = Path.Combine(settingsService.GetDbPath(), fileName);
var tmpPath = filePath + ".tmp";
var semaphore = GetLock(filePath);
await semaphore.WaitAsync();
try
{ {
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)); // XmlSerializer is synchronous — serialize to memory, then write async.
xsSubmit.Serialize(writer, obj); using var memory = new MemoryStream();
var xml = Encoding.UTF8.GetString(sww.ToArray()); using (var writer = XmlWriter.Create(memory, settings))
File.WriteAllText(filePath, xml); {
var serializer = new XmlSerializer(typeof(T));
serializer.Serialize(writer, obj);
}
await File.WriteAllBytesAsync(tmpPath, memory.ToArray());
// File.Move has no async form in .NET 9.
File.Move(tmpPath, filePath, overwrite: true);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to save {FilePath}", filePath);
if (File.Exists(tmpPath))
{
try { File.Delete(tmpPath); } catch { /* best effort */ }
}
throw;
} }
} }
finally
{
semaphore.Release();
}
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="LoadListAsync{T}"/> for repository reads.
/// </summary> /// </summary>
protected T Load<T>(string fileName) protected async Task<T> LoadAsync<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)) var semaphore = GetLock(filePath);
await semaphore.WaitAsync();
try
{ {
if (File.Exists(filePath) == false)
{
return default(T);
}
var bytes = await File.ReadAllBytesAsync(filePath);
using var memory = new MemoryStream(bytes);
var serializer = new XmlSerializer(typeof(T)); var serializer = new XmlSerializer(typeof(T));
return (T)serializer.Deserialize(reader); return (T)serializer.Deserialize(memory);
}
finally
{
semaphore.Release();
} }
} }
private static SemaphoreSlim GetLock(string filePath)
=> FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1));
} }
} }

View file

@ -1,6 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -10,55 +12,41 @@ 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, ILogger<BookingRuleRepository> logger)
: base(settingsService, cache, logger)
{ {
EnsureFileExisits(BOOKING_RULES); EnsureFileExists(BOOKING_RULES);
} }
/// <summary> public Task<List<BookingRule>> GetBookingRuleListAsync() => LoadListAsync<BookingRule>(BOOKING_RULES);
/// Load booking rules
/// </summary>
public List<BookingRule> GetBookingRuleList()
{
var bookingTexts = Load<List<BookingRule>>(BOOKING_RULES);
return bookingTexts ?? new List<BookingRule>();
}
/// <summary> public async Task SaveBookingRuleAsync(BookingRule bookingRule)
/// Save a booking rule (insert/update)
/// </summary>
public void SaveBookingRule(BookingRule bookingRule)
{ {
var bookingRules = GetBookingRuleList(); var bookingRules = await GetBookingRuleListAsync();
var existingRule = bookingRules.FirstOrDefault(a => a.Id == bookingRule.Id); var idx = bookingRules.FindIndex(r => r.Id == bookingRule.Id);
if (idx >= 0)
if (existingRule == null)
{ {
existingRule = new BookingRule(); bookingRules[idx] = bookingRule;
bookingRules.Add(existingRule);
bookingRule.Id = Guid.NewGuid().ToString();
} }
bookingRule.Copy(existingRule); else
Save(bookingRules, BOOKING_RULES); {
bookingRule.Id = Guid.NewGuid().ToString();
bookingRules.Add(bookingRule);
}
await SaveAsync(bookingRules, BOOKING_RULES);
} }
/// <summary> public async Task<BookingRule> GetBookingRuleAsync(string id)
/// Get booking rule
/// </summary>
public BookingRule GetBookingRule(string id)
{ {
var bookingRules = GetBookingRuleList(); var bookingRules = await GetBookingRuleListAsync();
return bookingRules.FirstOrDefault(t => t.Id == id); return bookingRules.FirstOrDefault(t => t.Id == id);
} }
/// <summary> public async Task DeleteBookingRuleAsync(string id)
/// Delete booking rule
/// </summary>
public void DeleteBookingRule(string id)
{ {
var bookingRules = GetBookingRuleList(); var bookingRules = await GetBookingRuleListAsync();
bookingRules.RemoveAll(t => t.Id == id); bookingRules.RemoveAll(t => t.Id == id);
Save(bookingRules, BOOKING_RULES); await SaveAsync(bookingRules, BOOKING_RULES);
} }
} }
} }

View file

@ -1,6 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -10,55 +12,41 @@ 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, ILogger<BookingTextRepository> logger)
: base(settingsService, cache, logger)
{ {
EnsureFileExisits(BOOKING_TEXTS); EnsureFileExists(BOOKING_TEXTS);
} }
/// <summary> public Task<List<BookingText>> GetBookingTextListAsync() => LoadListAsync<BookingText>(BOOKING_TEXTS);
/// Load bokking texts
/// </summary>
public List<BookingText> GetBookingTextList()
{
var bookingTexts = Load<List<BookingText>>(BOOKING_TEXTS);
return bookingTexts ?? new List<BookingText>();
}
/// <summary> public async Task SaveBookingTextAsync(BookingText bookingText)
/// Save a booking text (insert/update)
/// </summary>
public void SaveBookingText(BookingText bookingText)
{ {
var bookingTexts = GetBookingTextList(); var bookingTexts = await GetBookingTextListAsync();
var existingText = bookingTexts.FirstOrDefault(a => a.Id == bookingText.Id); var idx = bookingTexts.FindIndex(t => t.Id == bookingText.Id);
if (idx >= 0)
if (existingText == null)
{ {
existingText = new BookingText(); bookingTexts[idx] = bookingText;
bookingTexts.Add(existingText);
bookingText.Id = Guid.NewGuid().ToString();
} }
bookingText.Copy(existingText); else
Save(bookingTexts, BOOKING_TEXTS); {
bookingText.Id = Guid.NewGuid().ToString();
bookingTexts.Add(bookingText);
}
await SaveAsync(bookingTexts, BOOKING_TEXTS);
} }
/// <summary> public async Task<BookingText> GetBookingTextAsync(string id)
/// Get booking text
/// </summary>
public BookingText GetBookingText(string id)
{ {
var bookingTexts = GetBookingTextList(); var bookingTexts = await GetBookingTextListAsync();
return bookingTexts.FirstOrDefault(t => t.Id == id); return bookingTexts.FirstOrDefault(t => t.Id == id);
} }
/// <summary> public async Task DeleteBookingTextAsync(string id)
/// Delete booking text
/// </summary>
public void DeleteBookingText(string id)
{ {
var bookingTexts = GetBookingTextList(); var bookingTexts = await GetBookingTextListAsync();
bookingTexts.RemoveAll(t => t.Id == id); bookingTexts.RemoveAll(t => t.Id == id);
Save(bookingTexts, BOOKING_TEXTS); await SaveAsync(bookingTexts, BOOKING_TEXTS);
} }
} }
} }

View file

@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
namespace Schaad.Accounting.Repositories
{
public class SplitPredefinitionRepository : BaseRepository, ISplitPredefinitionRepository
{
private readonly string SPLIT_PREDEFINITION = "SplitPredefinitions.xml";
public SplitPredefinitionRepository(ISettingsService settingsService, RepositoryCache cache, ILogger<SplitPredefinitionRepository> logger)
: base(settingsService, cache, logger)
{
EnsureFileExists(SPLIT_PREDEFINITION);
}
public Task<List<SplitPredefinition>> GetSplitPredefinitionListAsync() => LoadListAsync<SplitPredefinition>(SPLIT_PREDEFINITION);
public async Task SaveSplitPredefinitionAsync(SplitPredefinition splitPredefinition)
{
var definitions = await GetSplitPredefinitionListAsync();
var idx = definitions.FindIndex(d => d.Id == splitPredefinition.Id);
if (idx >= 0)
{
definitions[idx] = splitPredefinition;
}
else
{
splitPredefinition.Id = Guid.NewGuid().ToString();
definitions.Add(splitPredefinition);
}
await SaveAsync(definitions, SPLIT_PREDEFINITION);
}
}
}

View file

@ -1,45 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
namespace Schaad.Accounting.Repositories
{
public class SplitPredefinitonRepository : BaseRepository, ISplitPredefinitonRepository
{
private readonly string SPLIT_PREDEFINITION = "SplitPredefinitions.xml";
public SplitPredefinitonRepository(ISettingsService settingsService) : base(settingsService)
{
EnsureFileExisits(SPLIT_PREDEFINITION);
}
/// <summary>
/// Load booking rules
/// </summary>
public List<SplitPredefinition> GetSplitPredefinitionList()
{
var definitions = Load<List<SplitPredefinition>>(SPLIT_PREDEFINITION);
return definitions ?? new List<SplitPredefinition>();
}
/// <summary>
/// Save a booking rule (insert/update)
/// </summary>
public void SaveSplitPredefinition(SplitPredefinition splitPredefinition)
{
var definitions = GetSplitPredefinitionList();
var existingDefinition = definitions.FirstOrDefault(a => a.Id == splitPredefinition.Id);
if (existingDefinition == null)
{
existingDefinition = new SplitPredefinition();
definitions.Add(existingDefinition);
splitPredefinition.Id = Guid.NewGuid().ToString();
}
splitPredefinition.Copy(existingDefinition);
Save(definitions, SPLIT_PREDEFINITION);
}
}
}

View file

@ -1,6 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -11,9 +13,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, ILogger<SubclassRepository> logger)
: base(settingsService, cache, logger)
{ {
EnsureFileExisits(SUBCLASSES); EnsureFileExists(SUBCLASSES);
classes.Add(ClassIds.Activa, "Aktiven"); classes.Add(ClassIds.Activa, "Aktiven");
classes.Add(ClassIds.Passiva, "Passiven"); classes.Add(ClassIds.Passiva, "Passiven");
@ -21,47 +24,35 @@ namespace Schaad.Accounting.Repositories
classes.Add(ClassIds.Expenses, "Ausgaben"); classes.Add(ClassIds.Expenses, "Ausgaben");
} }
/// <summary> public Task<List<SubClass>> GetSubClassListAsync() => LoadListAsync<SubClass>(SUBCLASSES);
/// Load bokking texts
/// </summary>
public List<SubClass> GetSubClassList()
{
var subclasses = Load<List<SubClass>>(SUBCLASSES);
return subclasses ?? new List<SubClass>();
}
/// <summary> public async Task SaveSubClassAsync(SubClass subClass)
/// Save a booking text (insert/update)
/// </summary>
public void SaveSubClass(SubClass subClass)
{ {
var subclasses = GetSubClassList(); var subclasses = await GetSubClassListAsync();
var existingSubClass = subclasses.FirstOrDefault(a => a.Id == subClass.Id); var idx = subclasses.FindIndex(s => s.Id == subClass.Id);
if (idx >= 0)
if (existingSubClass == null)
{ {
existingSubClass = new SubClass(); subclasses[idx] = subClass;
subclasses.Add(existingSubClass);
subClass.Id = Guid.NewGuid().ToString();
} }
subClass.Copy(existingSubClass); else
Save(subclasses, SUBCLASSES); {
subClass.Id = Guid.NewGuid().ToString();
subclasses.Add(subClass);
}
await SaveAsync(subclasses, SUBCLASSES);
} }
/// <summary> public async Task<SubClass> GetSubClassAsync(string id)
/// Get subclass
/// </summary>
public SubClass GetSubClass(string id)
{ {
var subclasses = GetSubClassList(); var subclasses = await GetSubClassListAsync();
return subclasses.FirstOrDefault(t => t.Id == id); return subclasses.FirstOrDefault(t => t.Id == id);
} }
public void DeleteSubClass(string id) public async Task DeleteSubClassAsync(string id)
{ {
var subclasses = GetSubClassList(); var subclasses = await GetSubClassListAsync();
subclasses.RemoveAll(t => t.Id == id); subclasses.RemoveAll(t => t.Id == id);
Save(subclasses, SUBCLASSES); await SaveAsync(subclasses, SUBCLASSES);
} }
public Dictionary<int, string> GetClasses() public Dictionary<int, string> GetClasses()

View file

@ -1,6 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -11,82 +13,79 @@ 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, ILogger<TransactionRepository> logger)
: base(settingsService, cache, logger)
{ {
this.accountRepository = accountRepository; this.accountRepository = accountRepository;
} }
/// <summary> public Task<List<Transaction>> GetTransactionListAsync() => LoadListAsync<Transaction>(TRANSACTIONS);
/// Get transaction list
/// </summary>
public List<Transaction> GetTransactionList()
{
var transactionList = Load<List<Transaction>>(TRANSACTIONS);
return transactionList ?? new List<Transaction>();
}
/// <summary> public async Task SaveTransactionAsync(Transaction transaction)
/// Save a transaction (insert/update)
/// </summary>
public void SaveTransaction(Transaction transaction)
{ {
transaction.Value = Math.Abs(transaction.Value); transaction.Value = Math.Abs(transaction.Value);
// prevent saving fxrate for chf account var originIsFx = (await accountRepository.GetAccountAsync(transaction.OriginAccountId)).IsFxAccount;
var isFxAccount = accountRepository.GetAccount(transaction.OriginAccountId).IsFxAccount; var targetIsFx = (await accountRepository.GetAccountAsync(transaction.TargetAccountId)).IsFxAccount;
var isFxAccount = originIsFx || targetIsFx;
// if source account is fx account, we pass a foreign value -> calc to chf // user enters amount in the foreign account's currency -> convert to CHF for storage
if (isFxAccount && transaction.FxRate != 0) if (isFxAccount && transaction.FxRate != 0)
{ {
transaction.Value = transaction.Value * transaction.FxRate.Value; transaction.Value = transaction.Value * transaction.FxRate.Value;
} }
isFxAccount |= accountRepository.GetAccount(transaction.TargetAccountId).IsFxAccount; if (!isFxAccount)
if (isFxAccount == false)
{ {
transaction.FxRate = null; transaction.FxRate = null;
} }
if (transaction.BookingDate <= DateTime.MinValue)
var transactionList = GetTransactionList(); {
var existingTransaction = transactionList.FirstOrDefault(a => a.Id == transaction.Id); transaction.BookingDate = transaction.ValueDate;
}
if (existingTransaction == null)
var transactionList = await GetTransactionListAsync();
var idx = transactionList.FindIndex(t => t.Id == transaction.Id);
if (idx >= 0)
{
transactionList[idx] = transaction;
}
else
{ {
existingTransaction = new Transaction();
transactionList.Add(existingTransaction);
transaction.Id = Guid.NewGuid().ToString(); transaction.Id = Guid.NewGuid().ToString();
transactionList.Add(transaction);
} }
transaction.Copy(existingTransaction); await SaveAsync(transactionList, TRANSACTIONS);
Save(transactionList, TRANSACTIONS);
} }
/// <summary> public async Task<Transaction> GetTransactionAsync(string id)
/// Get transaction
/// </summary>
public Transaction GetTransaction(string id)
{ {
var transactions = GetTransactionList(); var transactions = await GetTransactionListAsync();
var transaction = transactions.FirstOrDefault(t => t.Id == id); var stored = transactions.FirstOrDefault(t => t.Id == id);
if (stored == null)
// if source account is fx account, we save in chf currency -> calc to foreign currency for display
var isFxAccount = accountRepository.GetAccount(transaction.OriginAccountId).IsFxAccount;
if (isFxAccount && transaction.FxRate != 0)
{ {
transaction.Value = transaction.Value / transaction.FxRate.Value; return null;
} }
return transaction; var result = stored.Clone();
// value is stored in CHF -> convert back to foreign currency for display/editing
var isFxAccount = (await accountRepository.GetAccountAsync(result.OriginAccountId)).IsFxAccount
|| (await accountRepository.GetAccountAsync(result.TargetAccountId)).IsFxAccount;
if (isFxAccount && result.FxRate != 0)
{
result.Value = result.Value / result.FxRate.Value;
}
return result;
} }
/// <summary> public async Task DeleteTransactionAsync(string id)
/// Delete transaction
/// </summary>
public void DeleteTransaction(string id)
{ {
var transactions = GetTransactionList(); var transactions = await GetTransactionListAsync();
transactions.RemoveAll(t => t.Id == id); transactions.RemoveAll(t => t.Id == id);
Save(transactions, TRANSACTIONS); await SaveAsync(transactions, TRANSACTIONS);
} }
} }
} }

View file

@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
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.SaveAsync"/>,
/// 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 async Task<List<T>> GetOrLoadAsync<T>(string cacheKey, Func<Task<List<T>>> loader)
{
if (entries.TryGetValue(cacheKey, out var cached))
{
return (List<T>)cached;
}
var list = await loader();
entries[cacheKey] = list;
return list;
}
public void Invalidate(string cacheKey)
{
entries.Remove(cacheKey);
}
}
}

View file

@ -4,10 +4,11 @@
<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>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
</ItemGroup>
</Project> </Project>

View file

@ -1,6 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Datasets.Charts; using Schaad.Accounting.Datasets.Charts;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
@ -8,115 +10,135 @@ 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 async Task<IReadOnlyList<DataSerie>> GetExpensesPerMonthAsync()
{ {
var transactions = viewService.GetTransactionViewList().Where(a => a.TargetAccount.Class == ClassIds.Expenses); var expenseTransactions = (await viewService.GetTransactionViewListAsync())
.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(); ? await GetSubClassExpensesPerMonthAsync(expenseTransactions)
: await GetAccountExpensesPerMonthAsync();
} }
private List<DataSerie> GetSubClassExpensesPerMonth() private async Task<List<DataSerie>> GetSubClassExpensesPerMonthAsync(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 subClassNameByNumber = (await viewService.GetAccountViewListAsync())
foreach (var grp in transactions.GroupBy(a => a.TargetAccount.SubClass).Select(a => new {Key = a.Key, List = a.ToList()})) .GroupBy(a => a.SubClass)
{ .ToDictionary(g => g.Key, g => g.First().SubClassName);
// Sum subclass transactions per month
var groupedByMonth = grp.List.GroupBy(g => g.ValueDate.Month).ToDictionary(g => g.Key, g => g.ToList().Sum(s => s.Value));
EnsureEntryForEveryMonth(groupedByMonth, maxMonth);
//var subClass = subclasses.FirstOrDefault(s => s.Number == grp.List.First().TargetAccount.SubClass);
list.Add(
new DataSerie(
Id: grp.List.First().TargetAccount.SubClass.ToString(),
Name: grp.List.First().TargetAccount.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;
}
private List<DataSerie> GetAccountExpensesPerMonth()
{
var list = new List<DataSerie>(); var list = new List<DataSerie>();
var expensesAccounts = viewService.GetAccountViewList().Where(a => a.Class == ClassIds.Expenses); foreach (var grp in expenseTransactions.GroupBy(t => t.TargetAccount.SubClass))
foreach (var account in expensesAccounts)
{ {
var serie = GetAccountExpensesPerMonth(account.Id, DateTime.Now.Year); var groupedByMonth = grp
if (serie != null) .GroupBy(t => t.ValueDate.Month)
{ .ToDictionary(g => g.Key, g => g.Sum(t => t.GetValue(t.TargetAccount.IsFxAccount)));
list.Add(serie); EnsureEntryForEveryMonth(groupedByMonth, maxMonth);
}
list.Add(new DataSerie(
Id: grp.Key.ToString(),
Name: subClassNameByNumber[grp.Key],
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 async Task<List<DataSerie>> GetAccountExpensesPerMonthAsync()
{ {
if (settingsService.TrySetYear(year)) var year = settingsService.GetYear();
var allTransactions = await viewService.GetTransactionViewListAsync();
var expenseAccounts = (await viewService.GetAccountViewListAsync()).Where(a => a.Class == ClassIds.Expenses);
var list = new List<DataSerie>();
foreach (var account in expenseAccounts)
{ {
var accountList = accountRepository.GetAccountList(); var transactions = allTransactions.Where(t => t.TargetAccountId == account.Id).ToList();
var account = accountList.SingleOrDefault(a => a.Id == accountId); if (transactions.Count == 0)
// perhaps we dont have the account for last year
if (account == null)
{ {
return null; continue;
} }
var transactions = viewService.GetTransactionViewList().Where(t => t.TargetAccountId == accountId).ToList();
var newestTransaction = transactions.OrderByDescending(t => t.ValueDate).FirstOrDefault(); var maxMonth = transactions.Max(t => t.ValueDate.Month);
var maxMonth = newestTransaction?.ValueDate.Month ?? 12; var groupedByMonth = transactions
.GroupBy(t => t.ValueDate.Month)
var groupedByMonth = transactions.GroupBy(g => g.ValueDate.Month).ToDictionary(g => g.Key, g => g.ToList().Sum(s => s.Value)); .ToDictionary(g => g.Key, g => g.Sum(t => t.GetValue(account.IsFxAccount)));
EnsureEntryForEveryMonth(groupedByMonth, maxMonth); EnsureEntryForEveryMonth(groupedByMonth, maxMonth);
settingsService.SetYear(DateTime.Now.Year); list.Add(new DataSerie(
Id: account.Id,
return new DataSerie(
Id: accountId,
Name: account.Name, Name: account.Name,
X: groupedByMonth.OrderBy(g => g.Key).Select(g => new DateOnly(year, g.Key, 1)).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() Y: groupedByMonth.OrderBy(g => g.Key).Select(g => g.Value).ToList()
); ));
} }
return list;
return null;
} }
private void EnsureEntryForEveryMonth(Dictionary<int, decimal> values, int maxMonth = 12) public async Task<IReadOnlyList<DataSerie>> GetBookingTextExpensesPerMonthAsync(string accountId)
{
var account = await viewService.GetAccountViewAsync(accountId);
var transactions = await viewService.GetTransactionViewListAsync(accountId);
if (transactions.Count == 0)
{
return new List<DataSerie>();
}
var year = settingsService.GetYear();
var maxMonth = transactions.Max(t => t.ValueDate.Month);
var list = new List<DataSerie>();
foreach (var grp in transactions.GroupBy(t => t.GetTextGroup()))
{
var groupedByMonth = grp
.GroupBy(t => t.ValueDate.Month)
.ToDictionary(g => g.Key, g => g.Sum(t => t.GetValue(account.IsFxAccount, accountId)));
EnsureEntryForEveryMonth(groupedByMonth, maxMonth);
list.Add(new DataSerie(
Id: grp.Key,
Name: grp.Key,
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;
}
public async Task<IReadOnlyList<CategoryTotal>> GetBookingTextTotalsAsync(string accountId)
{
var account = await viewService.GetAccountViewAsync(accountId);
var transactions = await viewService.GetTransactionViewListAsync(accountId);
return transactions
.Where(t => t.TargetAccountId == accountId)
.GroupBy(t => t.GetTextGroup())
.Select(g => new CategoryTotal(g.Key, g.Sum(t => t.GetValue(account.IsFxAccount, accountId))))
.Where(c => c.Value != 0)
.ToList();
}
private static 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,8 +1,9 @@
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;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Datasets; using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Finance.Api; using Schaad.Finance.Api;
@ -18,6 +19,7 @@ namespace Schaad.Accounting.Services
private readonly ISettingsService settingsService; private readonly ISettingsService settingsService;
private readonly IAccountStatementService accountStatementService; private readonly IAccountStatementService accountStatementService;
private readonly ICreditCardStatementService creditCardStatementService; private readonly ICreditCardStatementService creditCardStatementService;
private readonly ILogger<FileService> logger;
public FileService( public FileService(
ISettingsService settingsService, ISettingsService settingsService,
@ -25,7 +27,8 @@ namespace Schaad.Accounting.Services
ITransactionRepository transactionsRepository, ITransactionRepository transactionsRepository,
IBankTransactionRepository bankTransactionRepository, IBankTransactionRepository bankTransactionRepository,
IAccountStatementService accountStatementService, IAccountStatementService accountStatementService,
ICreditCardStatementService creditCardStatementService) ICreditCardStatementService creditCardStatementService,
ILogger<FileService> logger)
{ {
this.settingsService = settingsService; this.settingsService = settingsService;
this.accountRepository = accountRepository; this.accountRepository = accountRepository;
@ -33,6 +36,7 @@ namespace Schaad.Accounting.Services
this.bankTransactionRepository = bankTransactionRepository; this.bankTransactionRepository = bankTransactionRepository;
this.accountStatementService = accountStatementService; this.accountStatementService = accountStatementService;
this.creditCardStatementService = creditCardStatementService; this.creditCardStatementService = creditCardStatementService;
this.logger = logger;
} }
public string Backup() public string Backup()
@ -44,19 +48,21 @@ namespace Schaad.Accounting.Services
} }
// Upload account statement file (mt940, camt053) // Upload account statement file (mt940, camt053)
// http://www.mikesdotnetting.com/article/288/asp-net-5-uploading-files-with-asp-net-mvc-6 public async Task<IReadOnlyList<MessageDataset>> ImportAccountStatementFileAsync(string filePath)
public IReadOnlyList<MessageDataset> ImportAccountStatementFile(string filePath)
{ {
logger.LogInformation("Importing account statement file {FilePath}", filePath);
var messages = new List<MessageDataset>(); var messages = new List<MessageDataset>();
var accountStatementResults = accountStatementService.ReadFile(filePath, Encoding.UTF8); var accountStatementResults = accountStatementService.ReadFile(filePath, Encoding.UTF8);
var accountList = accountRepository.GetAccountList().Select(a => a.BankAccountNumber).ToList(); var accountList = (await accountRepository.GetAccountListAsync()).Select(a => a.BankAccountNumber).ToList();
foreach (var accountStatementResult in accountStatementResults) foreach (var accountStatementResult in accountStatementResults)
{ {
var account = accountStatementResult.AccountStatement; var account = accountStatementResult.AccountStatement;
if (accountStatementResult.IsSuccess == false) if (accountStatementResult.IsSuccess == false)
{ {
logger.LogError("Statement parse failed for account {AccountNumber}: {Error}", account.AccountNumber, accountStatementResult.Error);
messages.Add(new MessageDataset($"Account {account.AccountNumber} NICHT importiert: {accountStatementResult.Error}", MessageStatus.Error)); messages.Add(new MessageDataset($"Account {account.AccountNumber} NICHT importiert: {accountStatementResult.Error}", MessageStatus.Error));
} }
@ -85,8 +91,11 @@ namespace Schaad.Accounting.Services
Creditor = t.Creditor Creditor = t.Creditor
}) })
.ToList(); .ToList();
var count = bankTransactionRepository.SaveBankTransactionList(account.AccountNumber, transactionsThisYear); var count = await bankTransactionRepository.SaveBankTransactionListAsync(account.AccountNumber, transactionsThisYear);
accountRepository.SaveBankAccountBalance(account.AccountNumber, (decimal)account.EndBalance.Value); await accountRepository.SaveBankAccountBalanceAsync(account.AccountNumber, (decimal)account.EndBalance.Value);
logger.LogInformation("Imported {Imported} of {Total} transactions for account {AccountNumber}",
count, account.Transactions.Count, account.AccountNumber);
var status = count == account.Transactions.Count ? MessageStatus.Success : MessageStatus.Info; var status = count == account.Transactions.Count ? MessageStatus.Success : MessageStatus.Info;
message.Add($"{count} von {account.Transactions.Count} Transaktion(en) importiert.", status); message.Add($"{count} von {account.Transactions.Count} Transaktion(en) importiert.", status);
@ -100,6 +109,8 @@ namespace Schaad.Accounting.Services
} }
else else
{ {
logger.LogWarning("Skipping account {AccountNumber}: not part of the currently selected mandator ({Skipped} transactions ignored)",
account.AccountNumber, account.Transactions.Count);
message.Add($"Falscher Mandant: {account.Transactions.Count} Transaktion(en) nicht importiert.", MessageStatus.Warning); message.Add($"Falscher Mandant: {account.Transactions.Count} Transaktion(en) nicht importiert.", MessageStatus.Warning);
} }
} }
@ -113,11 +124,11 @@ namespace Schaad.Accounting.Services
return creditCardTransactions; return creditCardTransactions;
} }
public byte[] GetTransactionListCsv(string accountId) public async Task<byte[]> GetTransactionListCsvAsync(string accountId)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
var account = accountRepository.GetAccount(accountId); var account = await accountRepository.GetAccountAsync(accountId);
var transactions = transactionsRepository.GetTransactionList() var transactions = (await transactionsRepository.GetTransactionListAsync())
.Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId) .Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId)
.OrderBy(t => t.BookingDate) .OrderBy(t => t.BookingDate)
.ThenBy(t => t.ValueDate) .ThenBy(t => t.ValueDate)
@ -130,28 +141,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

@ -0,0 +1,21 @@
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces;
using Schaad.Finance.Api;
namespace Schaad.Accounting.Services
{
public class FxConverter : IFxConverter
{
private readonly IFxService fxService;
private readonly SettingsDataset settings;
public FxConverter(IFxService fxService, SettingsDataset settings)
{
this.fxService = fxService;
this.settings = settings;
}
public decimal ConvertToChf(decimal amount, string fromCurrency)
=> fxService.ConvertCurrency(amount, fromCurrency, "CHF", settings.FixerIoApiKey);
}
}

View file

@ -0,0 +1,15 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Datasets.Charts;
namespace Schaad.Accounting.Interfaces
{
public interface IChartService
{
Task<IReadOnlyList<DataSerie>> GetExpensesPerMonthAsync();
Task<IReadOnlyList<DataSerie>> GetBookingTextExpensesPerMonthAsync(string accountId);
Task<IReadOnlyList<CategoryTotal>> GetBookingTextTotalsAsync(string accountId);
}
}

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Datasets; using Schaad.Accounting.Datasets;
using Schaad.Finance.Api; using Schaad.Finance.Api;
using Schaad.Finance.Api.Datasets; using Schaad.Finance.Api.Datasets;
@ -8,8 +9,8 @@ namespace Schaad.Accounting.Interfaces
public interface IFileService public interface IFileService
{ {
string Backup(); string Backup();
IReadOnlyList<MessageDataset> ImportAccountStatementFile(string filePath); Task<IReadOnlyList<MessageDataset>> ImportAccountStatementFileAsync(string filePath);
IReadOnlyList<CreditCardTransaction> ImportCreditCardStatementFile(CreditCardProvider creditCardProvider, string filePath); IReadOnlyList<CreditCardTransaction> ImportCreditCardStatementFile(CreditCardProvider creditCardProvider, string filePath);
byte[] GetTransactionListCsv(string accountId); Task<byte[]> GetTransactionListCsvAsync(string accountId);
} }
} }

View file

@ -0,0 +1,11 @@
namespace Schaad.Accounting.Interfaces
{
/// <summary>
/// Thin wrapper over the vendor <c>IFxService</c> that hides the FixerIo API key
/// and the base currency, so callers just ask "convert this amount to CHF".
/// </summary>
public interface IFxConverter
{
decimal ConvertToChf(decimal amount, string fromCurrency);
}
}

View file

@ -0,0 +1,32 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Datasets.Reports;
using Schaad.Accounting.Models;
using Schaad.Finance.Api.Datasets;
namespace Schaad.Accounting.Interfaces
{
public interface IViewService
{
Task<AccountDataset> GetAccountViewAsync(string id);
Task<List<AccountDataset>> GetAccountViewListAsync();
Task<BalanceDataset> GetBalanceViewAsync();
Task<BalanceSheetDataset> GetBalanceSheetViewAsync(int year);
Task<List<TransactionDataset>> GetTransactionViewListAsync();
Task<List<TransactionDataset>> GetTransactionViewListAsync(string accountId);
Task<List<BookingRuleDataset>> GetBookingRuleViewListAsync();
Task<List<BankTransaction>> GetOpenBankTransactionListAsync();
Task<List<Transaction>> MatchOpenBankTransactionsAsync();
Task<List<Transaction>> MatchCreditCardTransactionsAsync(string bankTransactionId, IReadOnlyList<CreditCardTransaction> creditCardTransactions);
}
}

View file

@ -6,7 +6,7 @@
<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" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" /> <ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />

View file

@ -10,12 +10,13 @@ namespace Schaad.Accounting.Services
public class SettingsService : ISettingsService public class SettingsService : ISettingsService
{ {
private readonly SettingsDataset settingsDataset; private readonly SettingsDataset settingsDataset;
private string mandator = "Claudio Schaad"; private string mandator;
private int year = DateTime.Now.Year; private int year = DateTime.Now.Year;
public SettingsService(SettingsDataset settingsDataset) public SettingsService(SettingsDataset settingsDataset)
{ {
this.settingsDataset = settingsDataset; this.settingsDataset = settingsDataset;
this.mandator = settingsDataset.DefaultMandator;
} }
public int GetYear() public int GetYear()
@ -30,7 +31,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

@ -1,11 +1,12 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Schaad.Accounting.Datasets; using Schaad.Accounting.Datasets;
using Schaad.Accounting.Datasets.Reports; using Schaad.Accounting.Datasets.Reports;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
using Schaad.Finance.Api;
using Schaad.Finance.Api.Datasets; using Schaad.Finance.Api.Datasets;
namespace Schaad.Accounting.Services namespace Schaad.Accounting.Services
@ -17,8 +18,8 @@ namespace Schaad.Accounting.Services
private readonly IBookingRuleRepository bookingRuleRepository; private readonly IBookingRuleRepository bookingRuleRepository;
private readonly ISubclassRepository subclassRepository; private readonly ISubclassRepository subclassRepository;
private readonly ITransactionRepository transactionRepository; private readonly ITransactionRepository transactionRepository;
private readonly IFxService fxService; private readonly IFxConverter fxConverter;
private readonly ISettingsService settingsService; private readonly ILogger<ViewService> logger;
public ViewService( public ViewService(
IAccountRepository accountRepository, IAccountRepository accountRepository,
@ -26,200 +27,165 @@ namespace Schaad.Accounting.Services
ITransactionRepository transactionRepository, ITransactionRepository transactionRepository,
ISubclassRepository subclassRepository, ISubclassRepository subclassRepository,
IBookingRuleRepository bookingRuleRepository, IBookingRuleRepository bookingRuleRepository,
IFxService fxService, IFxConverter fxConverter,
ISettingsService settingsService) ILogger<ViewService> logger)
{ {
this.accountRepository = accountRepository; this.accountRepository = accountRepository;
this.bankTransactionRepository = bankTransactionRepository; this.bankTransactionRepository = bankTransactionRepository;
this.transactionRepository = transactionRepository; this.transactionRepository = transactionRepository;
this.subclassRepository = subclassRepository; this.subclassRepository = subclassRepository;
this.bookingRuleRepository = bookingRuleRepository; this.bookingRuleRepository = bookingRuleRepository;
this.fxService = fxService; this.fxConverter = fxConverter;
this.settingsService = settingsService; this.logger = logger;
} }
/// <summary> public async Task<AccountDataset> GetAccountViewAsync(string id)
/// Load accounts
/// </summary>
public AccountDataset GetAccountView(string id)
{ {
var accounts = GetAccountViewList(); var accounts = await GetAccountViewListAsync();
return accounts.Single(t => t.Id == id); return accounts.Single(t => t.Id == id);
} }
/// <summary> public async Task<List<AccountDataset>> GetAccountViewListAsync()
/// Load accounts and calculates their balances
/// </summary>
public List<AccountDataset> GetAccountViewList()
{ {
var accounts = accountRepository.GetAccountList(); var accounts = await accountRepository.GetAccountListAsync();
var transactionList = GetTransactionViewList(); var transactionList = await GetTransactionViewListAsync();
var subClassNameByNumber = subclassRepository.GetSubClassList().ToDictionary(s => s.Number, s => s.Name); var subClassNameByNumber = (await subclassRepository.GetSubClassListAsync()).ToDictionary(s => s.Number, s => s.Name);
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: fxConverter.ConvertToChf(balance, a.Currency),
startBalanceCHF: fxConverter.ConvertToChf(a.StartBalance, a.Currency),
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();
} }
public BalanceDataset GetBalanceView() private static decimal ComputeBalance(
Account account,
Dictionary<string, List<TransactionDataset>> creditsByAccount,
Dictionary<string, List<TransactionDataset>> debitsByAccount)
{ {
var accountList = GetAccountViewList(); var balance = account.StartBalance;
var balanceView = new BalanceDataset( if (creditsByAccount.TryGetValue(account.Id, out var credits))
activaAccountList: accountList.Where(m => m.Class == ClassIds.Activa).ToList(), {
passivaAccountList: accountList.Where(m => m.Class == ClassIds.Passiva).ToList(), balance += credits.Sum(t => t.GetValue(account.IsFxAccount));
totalActivaCHF: Math.Abs(accountList.Where(m => m.Class == ClassIds.Activa).Sum(m => m.BalanceCHF)), }
totalPassivaCHF: Math.Abs(accountList.Where(m => m.Class == ClassIds.Passiva).Sum(m => m.BalanceCHF)) if (debitsByAccount.TryGetValue(account.Id, out var debits))
); {
balance -= debits.Sum(t => t.GetValue(account.IsFxAccount));
return balanceView; }
}
public BalanceSheetDataset GetBalanceSheetView(int year)
{
var accountList = GetAccountViewList();
var settings = settingsService.GetSettings();
var profit = Math.Abs(accountList.Where(m => m.Class == ClassIds.Income).Sum(m =>m.BalanceCHF));
profit += Math.Abs(accountList.Where(m => m.Class == ClassIds.Activa).Sum(m => fxService.ConvertCurrency(m.StartBalance, m.Currency, "CHF", settings.FixerIoApiKey)));
var loss = Math.Abs(accountList.Where(m => m.Class == ClassIds.Expenses).Sum(m => m.BalanceCHF));
var balanceView = new BalanceSheetDataset(
activaAccountList: accountList.Where(m => m.Class == ClassIds.Activa).ToList(),
incomeAccountList: accountList.Where(m => m.Class == ClassIds.Income).ToList(),
expensesAccountList: accountList.Where(m => m.Class == ClassIds.Expenses).ToList(),
profitCHF: profit,
lossCHF: loss,
year: year
);
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; return balance;
} }
private decimal GetCurrentBalanceInCHF(Account a, List<TransactionDataset> transactionList) public async Task<BalanceDataset> GetBalanceViewAsync()
{ {
var settings = settingsService.GetSettings(); var accountList = await GetAccountViewListAsync();
var balanceInAccountCurrency = GetBalanceInAccountCurrency(a, transactionList); return new BalanceDataset(
var balanceInChf = fxService.ConvertCurrency(balanceInAccountCurrency, a.Currency, "CHF", settings.FixerIoApiKey); ActivaAccountList: accountList.Where(m => m.Class == ClassIds.Activa).ToList(),
return balanceInChf; PassivaAccountList: accountList.Where(m => m.Class == ClassIds.Passiva).ToList(),
TotalActivaCHF: Math.Abs(accountList.Where(m => m.Class == ClassIds.Activa).Sum(m => m.BalanceCHF)),
TotalPassivaCHF: Math.Abs(accountList.Where(m => m.Class == ClassIds.Passiva).Sum(m => m.BalanceCHF)));
}
public async Task<BalanceSheetDataset> GetBalanceSheetViewAsync(int year)
{
var accountList = await GetAccountViewListAsync();
var profit = Math.Abs(accountList.Where(m => m.Class == ClassIds.Income).Sum(m => m.BalanceCHF));
profit += Math.Abs(accountList.Where(m => m.Class == ClassIds.Activa).Sum(m => fxConverter.ConvertToChf(m.StartBalance, m.Currency)));
var loss = Math.Abs(accountList.Where(m => m.Class == ClassIds.Expenses).Sum(m => m.BalanceCHF));
return new BalanceSheetDataset(
ActivaAccountList: accountList.Where(m => m.Class == ClassIds.Activa).ToList(),
IncomeAccountList: accountList.Where(m => m.Class == ClassIds.Income).ToList(),
ExpensesAccountList: accountList.Where(m => m.Class == ClassIds.Expenses).ToList(),
ProfitCHF: profit,
LossCHF: loss,
Year: year);
} }
/// <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 async Task<List<TransactionDataset>> GetTransactionViewListAsync()
{ {
var accountList = accountRepository.GetAccountList(); var accountsById = (await accountRepository.GetAccountListAsync()).ToDictionary(a => a.Id);
var transactionList = transactionRepository.GetTransactionList(); var transactionList = await transactionRepository.GetTransactionListAsync();
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>
/// Get transaction list with the origin and target account for each transaction /// Get transaction list filtered to a single account. Sign is not baked in here -
/// callers use <see cref="TransactionDataset.GetValue(bool, string)"/> with this
/// account's id to get a value that's negative when the account is paying out.
/// </summary> /// </summary>
public List<TransactionDataset> GetTransactionViewList(string accountId) public async Task<List<TransactionDataset>> GetTransactionViewListAsync(string accountId)
{ {
var accountList = accountRepository.GetAccountList(); var accountsById = (await accountRepository.GetAccountListAsync()).ToDictionary(a => a.Id);
var transactionList = transactionRepository.GetTransactionList().Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId); var transactionList = (await transactionRepository.GetTransactionListAsync())
.Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId);
var transactionViewList = transactionList.Select( return transactionList.Select(t =>
t => new TransactionDataset(
new TransactionDataset( 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 Prepare(Transaction t)
{
var account = accountList.Single(a => a.Id == t.OriginAccountId);
if (account.Class == ClassIds.Activa && accountId == t.OriginAccountId )
{
t.Value *= -1;
}
return t;
}
} }
/// <summary> /// <summary>
/// Get booking rules with their account /// Get booking rules with their account
/// </summary> /// </summary>
public List<BookingRuleDataset> GetBookingRuleViewList() public async Task<List<BookingRuleDataset>> GetBookingRuleViewListAsync()
{ {
var accountList = accountRepository.GetAccountList(); var accountsById = (await accountRepository.GetAccountListAsync()).ToDictionary(a => a.Id);
var bookinRuleList = bookingRuleRepository.GetBookingRuleList(); var bookingRules = await bookingRuleRepository.GetBookingRuleListAsync();
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();
} }
/// <summary> /// <summary>
/// Load open bank transactions /// Load open bank transactions
/// </summary> /// </summary>
public List<BankTransaction> GetOpenBankTransactionList() public async Task<List<BankTransaction>> GetOpenBankTransactionListAsync()
{ {
var transactions = transactionRepository var transactions = (await transactionRepository.GetTransactionListAsync())
.GetTransactionList()
.Where(t => string.IsNullOrEmpty(t.BankTransactionId) == false) .Where(t => string.IsNullOrEmpty(t.BankTransactionId) == false)
.Select(t => t.BankTransactionId); .Select(t => t.BankTransactionId);
var bankTransactions = bankTransactionRepository var bankTransactions = (await bankTransactionRepository.GetBankTransactionListAsync())
.GetBankTransactionList()
.Where(b => b.Ignore == false && transactions.Contains(b.Id) == false) .Where(b => b.Ignore == false && transactions.Contains(b.Id) == false)
.ToList(); .ToList();
return bankTransactions; return bankTransactions;
} }
public List<Transaction> MatchOpenBankTransactions() public async Task<List<Transaction>> MatchOpenBankTransactionsAsync()
{ {
var newTransactionList = new List<Transaction>(); var newTransactionList = new List<Transaction>();
var transactions = transactionRepository.GetTransactionList(); var transactions = await transactionRepository.GetTransactionListAsync();
var accounts = accountRepository.GetAccountList(); var accounts = await accountRepository.GetAccountListAsync();
var bookingRules = bookingRuleRepository.GetBookingRuleList(); var bookingRules = await bookingRuleRepository.GetBookingRuleListAsync();
var bankTransactions = GetOpenBankTransactionList().OrderBy(t => t.ValueDate); var bankTransactions = (await GetOpenBankTransactionListAsync()).OrderBy(t => t.ValueDate).ToList();
foreach (var bankTransaction in bankTransactions) foreach (var bankTransaction in bankTransactions)
{ {
var trx = new Transaction(bankTransaction, accounts); var trx = new Transaction(bankTransaction, accounts);
@ -228,14 +194,18 @@ namespace Schaad.Accounting.Services
MatchBankTransactionBySameValueLastMonth(bankTransaction, trx, transactions); MatchBankTransactionBySameValueLastMonth(bankTransaction, trx, transactions);
newTransactionList.Add(trx); newTransactionList.Add(trx);
} }
var matched = newTransactionList.Count(t => string.IsNullOrEmpty(t.Text) == false);
logger.LogInformation("Matched {Matched} of {Total} open bank transactions", matched, bankTransactions.Count);
return newTransactionList; return newTransactionList;
} }
public List<Transaction> MatchCreditCardTransactions(string bankTransactionId, IReadOnlyList<CreditCardTransaction> creditCardTransactions) public async Task<List<Transaction>> MatchCreditCardTransactionsAsync(string bankTransactionId, IReadOnlyList<CreditCardTransaction> creditCardTransactions)
{ {
var accounts = accountRepository.GetAccountList(); var accounts = await accountRepository.GetAccountListAsync();
var bankTrx = bankTransactionRepository.GetBankTransaction(bankTransactionId); var bankTrx = await bankTransactionRepository.GetBankTransactionAsync(bankTransactionId);
var bookingRules = bookingRuleRepository.GetBookingRuleList(); var bookingRules = await bookingRuleRepository.GetBookingRuleListAsync();
var trxList = new List<Transaction>(); var trxList = new List<Transaction>();
foreach (var creditCardTransaction in creditCardTransactions) foreach (var creditCardTransaction in creditCardTransactions)

View file

@ -0,0 +1,113 @@
using Microsoft.Extensions.Logging.Abstractions;
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(), NullLogger<AccountRepository>.Instance);
}
public void Dispose()
{
if (Directory.Exists(dbDir))
{
Directory.Delete(dbDir, recursive: true);
}
}
[Fact]
public async Task AssignNewIdWhenSavingAccountWithoutId()
{
var account = new Account { Number = 1000, Name = "Cash", Currency = "CHF" };
await sut.SaveAccountAsync(account);
var stored = (await sut.GetAccountListAsync()).Single();
stored.Id.ShouldNotBeNullOrEmpty();
stored.Name.ShouldBe("Cash");
}
[Fact]
public async Task UpdateInPlaceWhenSavingAccountWithExistingId()
{
await sut.SaveAccountAsync(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
var id = (await sut.GetAccountListAsync()).Single().Id;
await sut.SaveAccountAsync(new Account { Id = id, Number = 1000, Name = "Cash renamed", Currency = "CHF" });
var accounts = await sut.GetAccountListAsync();
accounts.Count.ShouldBe(1);
accounts[0].Name.ShouldBe("Cash renamed");
}
[Fact]
public async Task DefaultCurrencyToChfWhenSavingAccountWithoutCurrency()
{
await sut.SaveAccountAsync(new Account { Number = 1000, Name = "Cash", Currency = null! });
(await sut.GetAccountListAsync()).Single().Currency.ShouldBe("CHF");
}
[Fact]
public async Task RemoveAccountWhenDeletingById()
{
await sut.SaveAccountAsync(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
var id = (await sut.GetAccountListAsync()).Single().Id;
await sut.DeleteAccountAsync(id);
(await sut.GetAccountListAsync()).ShouldBeEmpty();
}
[Fact]
public async Task ReturnAccountWhenLookingUpByBankAccountNumber()
{
await sut.SaveAccountAsync(new Account { Number = 1010, Name = "Checking", Currency = "CHF", BankAccountNumber = "CH01" });
await sut.SaveAccountAsync(new Account { Number = 1020, Name = "Savings", Currency = "CHF", BankAccountNumber = "CH02" });
(await sut.GetAccountForBankAccountNumberAsync("CH01")).Name.ShouldBe("Checking");
(await sut.GetAccountForBankAccountNumberAsync("CH02")).Name.ShouldBe("Savings");
(await sut.GetAccountForBankAccountNumberAsync("CH99")).ShouldBeNull();
}
[Fact]
public async Task UpdateLastBankBalanceWhenSavingBankBalance()
{
await sut.SaveAccountAsync(new Account { Number = 1010, Name = "Checking", Currency = "CHF", BankAccountNumber = "CH01" });
await sut.SaveBankAccountBalanceAsync("CH01", 1234.56m);
(await sut.GetAccountForBankAccountNumberAsync("CH01")).LastBankBalance.ShouldBe(1234.56m);
}
[Fact]
public async Task SurviveConstructorReRunWhenAccountsFileAlreadyExists()
{
await sut.SaveAccountAsync(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(), NullLogger<AccountRepository>.Instance);
var accounts = await fresh.GetAccountListAsync();
accounts.Count.ShouldBe(1);
accounts[0].Name.ShouldBe("Cash");
}
}

View file

@ -0,0 +1,317 @@
using System.Linq;
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 async Task HonourSelectedYearWhenBuildingMonthlySeries()
{
settingsService.GetYear().Returns(2023);
var groceries = MakeAccount("groc", 4200);
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset> { AsDataset(groceries), AsDataset(checking) });
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2023, 4, 5))
});
var series = await BuildService().GetExpensesPerMonthAsync();
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 async Task GroupExpensesByAccountWhenAllExpensesShareOneSubClass()
{
var mannenbach = MakeAccount("m1", 4000);
var mannenbach2 = MakeAccount("m2", 4001);
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
{
AsDataset(mannenbach), AsDataset(mannenbach2), AsDataset(checking)
});
viewService.GetTransactionViewListAsync().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 = await BuildService().GetExpensesPerMonthAsync();
series.Count.ShouldBe(2);
series.ShouldContain(s => s.Id == "m1");
series.ShouldContain(s => s.Id == "m2");
}
[Fact]
public async Task GroupExpensesBySubClassWhenMultipleSubClassesAreUsed()
{
var groceries = MakeAccount("groc", 4200);
var utilities = MakeAccount("util", 4300);
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
{
AsDataset(groceries, subClassName: "Groceries"), AsDataset(utilities, subClassName: "Utilities"), AsDataset(checking)
});
viewService.GetTransactionViewListAsync().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 = await BuildService().GetExpensesPerMonthAsync();
series.Count.ShouldBe(2);
series.ShouldContain(s => s.Id == "42" && s.Name == "Groceries");
series.ShouldContain(s => s.Id == "43" && s.Name == "Utilities");
}
[Fact]
public async Task SkipAccountsWithoutTransactionsWhenBuildingSeries()
{
var groceries = MakeAccount("groc", 4000);
var utilities = MakeAccount("util", 4001);
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
{
AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
});
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 1))
});
var series = await BuildService().GetExpensesPerMonthAsync();
series.Count.ShouldBe(1);
series[0].Id.ShouldBe("groc");
}
private static Account MakeAccount(string id, int number, string currency = "CHF") => new()
{
Id = id,
Number = number,
Name = id,
Currency = currency
};
private static AccountDataset AsDataset(Account a, string subClassName = "") =>
new(a, balance: 0, balanceCHF: 0, startBalanceCHF: 0, className: "", subClassName: subClassName);
private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, DateTime valueDate, decimal? fxRate = null, string text = "") =>
new(
new Transaction
{
Id = Guid.NewGuid().ToString(),
OriginAccountId = origin.Id,
TargetAccountId = target.Id,
Value = value,
FxRate = fxRate,
Text = text,
ValueDate = valueDate,
BookingDate = valueDate
},
origin, target);
[Fact]
public async Task ConvertFxAccountValuesWhenBuildingAccountSeries()
{
var usSubscription = MakeAccount("us", 4000, currency: "USD");
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset> { AsDataset(usSubscription), AsDataset(checking) });
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, usSubscription, value: 100m, valueDate: new DateTime(2026, 1, 1), fxRate: 0.5m)
});
var series = await BuildService().GetExpensesPerMonthAsync();
series.Single().Y.Single().ShouldBe(200m);
}
[Fact]
public async Task ConvertFxAccountValuesWhenBuildingSubClassSeries()
{
var usSubscription = MakeAccount("us", 4200, currency: "USD");
var utilities = MakeAccount("util", 4300);
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewListAsync().Returns(new List<AccountDataset>
{
AsDataset(usSubscription), AsDataset(utilities), AsDataset(checking)
});
viewService.GetTransactionViewListAsync().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, usSubscription, value: 100m, valueDate: new DateTime(2026, 1, 1), fxRate: 0.5m),
MakeTransaction(checking, utilities, value: 30m, valueDate: new DateTime(2026, 2, 1))
});
var series = await BuildService().GetExpensesPerMonthAsync();
series.Single(s => s.Id == "42").Y[0].ShouldBe(200m);
}
[Fact]
public async Task GroupBookingTextExpensesByTextWhenBuildingMonthlySeriesForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"),
MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 2, 3), text: "Coop")
});
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
series.Count.ShouldBe(2);
var migros = series.Single(s => s.Name == "Migros");
migros.Y[0].ShouldBe(-50m);
migros.Y[1].ShouldBe(0m);
var coop = series.Single(s => s.Name == "Coop");
coop.Y[0].ShouldBe(0m);
coop.Y[1].ShouldBe(-20m);
}
[Fact]
public async Task GroupBookingTextsByFirstWordWhenBuildingMonthlySeriesForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Narbonne Car"),
MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 1, 6), text: "Narbonne Food")
});
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
var narbonne = series.Single(s => s.Name == "Narbonne");
narbonne.Y[0].ShouldBe(-70m);
}
[Fact]
public async Task ShowPositiveValueWhenAccountIsTargetOfBookingTextTransaction()
{
var checking = MakeAccount("chk", 1010);
var salary = MakeAccount("sal", 3000);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
{
MakeTransaction(salary, checking, value: 5000m, valueDate: new DateTime(2026, 1, 5), text: "Lohn")
});
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
series.Single().Y.Single().ShouldBe(5000m);
}
[Fact]
public async Task ConvertFxAccountValuesWhenBuildingBookingTextSeries()
{
var usChecking = MakeAccount("chk", 1010, currency: "USD");
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("chk").Returns(AsDataset(usChecking));
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
{
MakeTransaction(usChecking, groceries, value: 100m, valueDate: new DateTime(2026, 1, 5), fxRate: 0.5m, text: "Migros")
});
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
series.Single().Y.Single().ShouldBe(-200m);
}
[Fact]
public async Task SumBookingTextTotalsAcrossMonthsWhenBuildingTotalsForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("groc").Returns(AsDataset(groceries));
viewService.GetTransactionViewListAsync("groc").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"),
MakeTransaction(checking, groceries, value: 30m, valueDate: new DateTime(2026, 2, 3), text: "Migros"),
MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 2, 4), text: "Coop")
});
var totals = await BuildService().GetBookingTextTotalsAsync("groc");
totals.Count.ShouldBe(2);
totals.ShouldContain(t => t.Name == "Migros" && t.Value == 80m);
totals.ShouldContain(t => t.Name == "Coop" && t.Value == 20m);
}
[Fact]
public async Task GroupBookingTextsByFirstWordWhenBuildingTotalsForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("groc").Returns(AsDataset(groceries));
viewService.GetTransactionViewListAsync("groc").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Narbonne Car"),
MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 1, 6), text: "Narbonne Food")
});
var totals = await BuildService().GetBookingTextTotalsAsync("groc");
totals.Single().Name.ShouldBe("Narbonne");
totals.Single().Value.ShouldBe(70m);
}
[Fact]
public async Task ExcludeBookingsWhereAccountIsOriginWhenBuildingTotalsForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("groc").Returns(AsDataset(groceries));
viewService.GetTransactionViewListAsync("groc").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"),
MakeTransaction(groceries, checking, value: 20m, valueDate: new DateTime(2026, 1, 6), text: "Migros")
});
var totals = await BuildService().GetBookingTextTotalsAsync("groc");
totals.Single().Value.ShouldBe(50m);
}
[Fact]
public async Task ExcludeZeroNetBookingTextsWhenBuildingTotalsForAccount()
{
var checking = MakeAccount("chk", 1010);
var groceries = MakeAccount("groc", 4200);
viewService.GetAccountViewAsync("groc").Returns(AsDataset(groceries));
viewService.GetTransactionViewListAsync("groc").Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"),
MakeTransaction(checking, groceries, value: -50m, valueDate: new DateTime(2026, 1, 6), text: "Migros")
});
var totals = await BuildService().GetBookingTextTotalsAsync("groc");
totals.ShouldBeEmpty();
}
}

View file

@ -0,0 +1,98 @@
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
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()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
private FileService BuildService() => new(
settingsService, accountRepo, transactionRepo, bankTransactionRepo,
accountStatementService, creditCardStatementService, NullLogger<FileService>.Instance);
[Fact]
public async Task EmitHeaderRowAndRunningBalanceWhenExportingTransactionsCsv()
{
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF", StartBalance = 1000m };
accountRepo.GetAccountAsync("chk").Returns(checking);
transactionRepo.GetTransactionListAsync().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 = await BuildService().GetTransactionListCsvAsync("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");
lines[2].ShouldBe("05.01.2026;05.01.2026;Coop;50.00;;950.00");
lines[3].ShouldBe("25.01.2026;25.01.2026;Salary;;5'000.00;5'950.00");
}
[Fact]
public async Task OrderTransactionsByBookingDateThenValueDateThenValueWhenExporting()
{
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF", StartBalance = 0m };
accountRepo.GetAccountAsync("chk").Returns(checking);
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
{
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(await BuildService().GetTransactionListCsvAsync("chk"));
var lines = csv.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
lines[2].ShouldContain("earlier small");
lines[3].ShouldContain("earlier large");
lines[4].ShouldContain("later");
}
[Fact]
public async Task NotMutateTransactionValueWhenExportingCsv()
{
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF" };
accountRepo.GetAccountAsync("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.GetTransactionListAsync().Returns(new List<Transaction> { outgoing });
await BuildService().GetTransactionListCsvAsync("chk");
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,34 @@
using NSubstitute;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Services;
using Schaad.Finance.Api;
using Shouldly;
namespace Schaad.Accounting.Tests;
public class FxConverterTestShould
{
private readonly IFxService fxService = Substitute.For<IFxService>();
private readonly SettingsDataset settings = new() { DataPath = "", FixerIoApiKey = "test-key" };
private FxConverter BuildConverter() => new(fxService, settings);
[Fact]
public void DelegateToFxServiceWithChfAsTargetAndConfiguredApiKeyWhenConverting()
{
fxService.ConvertCurrency(100m, "USD", "CHF", "test-key").Returns(90m);
var result = BuildConverter().ConvertToChf(100m, "USD");
result.ShouldBe(90m);
fxService.Received(1).ConvertCurrency(100m, "USD", "CHF", "test-key");
}
[Fact]
public void PassNegativeAmountsThroughWhenConverting()
{
fxService.ConvertCurrency(-50m, "EUR", "CHF", Arg.Any<string>()).Returns(-48m);
BuildConverter().ConvertToChf(-50m, "EUR").ShouldBe(-48m);
}
}

View file

@ -0,0 +1,58 @@
using Schaad.Accounting.Repositories;
using Shouldly;
namespace Schaad.Accounting.Tests;
public class RepositoryCacheTestShould
{
[Fact]
public async Task CallLoaderOnceWhenKeyIsRequestedRepeatedly()
{
var cache = new RepositoryCache();
var loaderCalls = 0;
var first = await cache.GetOrLoadAsync("key", () => { loaderCalls++; return Task.FromResult(new List<int> { 1, 2 }); });
var second = await cache.GetOrLoadAsync("key", () => { loaderCalls++; return Task.FromResult(new List<int> { 99 }); });
loaderCalls.ShouldBe(1);
second.ShouldBeSameAs(first);
second.ShouldBe(new[] { 1, 2 });
}
[Fact]
public async Task KeepEntriesSeparateWhenKeysDiffer()
{
var cache = new RepositoryCache();
var a = await cache.GetOrLoadAsync("a", () => Task.FromResult(new List<int> { 1 }));
var b = await cache.GetOrLoadAsync("b", () => Task.FromResult(new List<int> { 2 }));
b.ShouldNotBeSameAs(a);
a.ShouldBe(new[] { 1 });
b.ShouldBe(new[] { 2 });
}
[Fact]
public async Task ReloadFromLoaderWhenKeyIsInvalidated()
{
var cache = new RepositoryCache();
var loaderCalls = 0;
await cache.GetOrLoadAsync("k", () => { loaderCalls++; return Task.FromResult(new List<int> { 1 }); });
cache.Invalidate("k");
await cache.GetOrLoadAsync("k", () => { loaderCalls++; return Task.FromResult(new List<int> { 2 }); });
loaderCalls.ShouldBe(2);
}
[Fact]
public async Task MatchKeysCaseInsensitivelyWhenLookingUp()
{
var cache = new RepositoryCache();
await cache.GetOrLoadAsync("Path/To/File.xml", () => Task.FromResult(new List<int> { 1 }));
var upper = await cache.GetOrLoadAsync("PATH/TO/FILE.XML", () => Task.FromResult(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,85 @@
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Models;
using Shouldly;
namespace Schaad.Accounting.Tests;
public class TransactionDatasetTestShould
{
private static Account MakeAccount(string id) => new() { Id = id, Name = id, Currency = "CHF" };
private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, decimal? fxRate = null, string text = "") =>
new(
new Transaction
{
Id = Guid.NewGuid().ToString(),
OriginAccountId = origin.Id,
TargetAccountId = target.Id,
Value = value,
FxRate = fxRate,
Text = text,
ValueDate = DateTime.Now,
BookingDate = DateTime.Now
},
origin, target);
[Fact]
public void ReturnNegativeValueWhenAccountIsOrigin()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(checking, groceries, value: 50m);
transaction.GetValue(withFxRate: false, accountId: "chk").ShouldBe(-50m);
}
[Fact]
public void ReturnPositiveValueWhenAccountIsTarget()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(groceries, checking, value: 50m);
transaction.GetValue(withFxRate: false, accountId: "chk").ShouldBe(50m);
}
[Fact]
public void ApplyFxConversionBeforeApplyingSignWhenAccountIsOrigin()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(checking, groceries, value: 100m, fxRate: 0.5m);
transaction.GetValue(withFxRate: true, accountId: "chk").ShouldBe(-200m);
}
[Fact]
public void ReturnFirstWordWhenTextHasMultipleWords()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(checking, groceries, value: 50m, text: "Narbonne Car");
transaction.GetTextGroup().ShouldBe("Narbonne");
}
[Fact]
public void ReturnWholeTextWhenTextHasNoSpace()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(checking, groceries, value: 50m, text: "Migros");
transaction.GetTextGroup().ShouldBe("Migros");
}
[Fact]
public void ReturnEmptyStringWhenTextIsEmpty()
{
var checking = MakeAccount("chk");
var groceries = MakeAccount("groc");
var transaction = MakeTransaction(checking, groceries, value: 50m, text: "");
transaction.GetTextGroup().ShouldBe("");
}
}

View file

@ -0,0 +1,173 @@
using Microsoft.Extensions.Logging.Abstractions;
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, NullLogger<TransactionRepository>.Instance);
}
public void Dispose()
{
if (Directory.Exists(dbDir))
{
Directory.Delete(dbDir, recursive: true);
}
}
[Fact]
public async Task PreserveValueWhenRoundTrippingNonFxTransaction()
{
accountRepo.GetAccountAsync("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
accountRepo.GetAccountAsync("groc").Returns(new Account { Id = "groc", Currency = "CHF" });
await sut.SaveTransactionAsync(new Transaction
{
OriginAccountId = "chk",
TargetAccountId = "groc",
Value = 42m,
Text = "Groceries",
ValueDate = new DateTime(2026, 5, 10),
BookingDate = new DateTime(2026, 5, 10)
});
var stored = (await sut.GetTransactionListAsync()).Single();
var loaded = await sut.GetTransactionAsync(stored.Id);
loaded.ShouldNotBeNull();
loaded.Value.ShouldBe(42m);
loaded.FxRate.ShouldBeNull();
}
[Fact]
public async Task StoreInChfAndReturnInAccountCurrencyWhenTransactionIsFx()
{
accountRepo.GetAccountAsync("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
accountRepo.GetAccountAsync("usd").Returns(new Account { Id = "usd", Currency = "USD" });
await sut.SaveTransactionAsync(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 = (await sut.GetTransactionListAsync()).Single();
stored.Value.ShouldBe(90m);
var loaded = await sut.GetTransactionAsync(stored.Id);
loaded.ShouldNotBeNull();
loaded.Value.ShouldBe(100m);
loaded.FxRate.ShouldBe(0.9m);
}
[Fact]
public async Task ReturnNullWhenGettingUnknownTransactionId()
{
(await sut.GetTransactionAsync("no-such-id")).ShouldBeNull();
}
[Fact]
public async Task NotMutateStoredValueWhenGettingFxTransactionTwice()
{
accountRepo.GetAccountAsync("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
accountRepo.GetAccountAsync("usd").Returns(new Account { Id = "usd", Currency = "USD" });
await sut.SaveTransactionAsync(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 = (await sut.GetTransactionListAsync()).Single();
var id = stored.Id;
var first = await sut.GetTransactionAsync(id);
var second = await sut.GetTransactionAsync(id);
first.ShouldNotBeNull();
first.Value.ShouldBe(100m);
second.ShouldNotBeNull();
second.Value.ShouldBe(100m);
}
[Fact]
public async Task RemoveTransactionWhenDeletingById()
{
accountRepo.GetAccountAsync(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
await sut.SaveTransactionAsync(new Transaction { OriginAccountId = "a", TargetAccountId = "b", Value = 1m, Text = "x", ValueDate = DateTime.Today });
var id = (await sut.GetTransactionListAsync()).Single().Id;
await sut.DeleteTransactionAsync(id);
(await sut.GetTransactionListAsync()).ShouldBeEmpty();
}
[Fact]
public async Task PreserveRelatedPartyWhenRoundTrippingTransaction()
{
accountRepo.GetAccountAsync(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
await sut.SaveTransactionAsync(new Transaction
{
OriginAccountId = "a",
TargetAccountId = "b",
Value = 10m,
Text = "Rent",
RelatedParty = "ACME Property AG",
ValueDate = new DateTime(2026, 4, 1),
BookingDate = new DateTime(2026, 4, 1)
});
(await sut.GetTransactionListAsync()).Single().RelatedParty.ShouldBe("ACME Property AG");
}
[Fact]
public async Task DefaultBookingDateToValueDateWhenBookingDateIsUnset()
{
accountRepo.GetAccountAsync(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
await sut.SaveTransactionAsync(new Transaction
{
OriginAccountId = "a",
TargetAccountId = "b",
Value = 10m,
Text = "x",
ValueDate = new DateTime(2026, 4, 15)
});
(await sut.GetTransactionListAsync()).Single().BookingDate.ShouldBe(new DateTime(2026, 4, 15));
}
}

View file

@ -0,0 +1,200 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.Services;
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 IFxConverter fxConverter = Substitute.For<IFxConverter>();
public ViewServiceTestShould()
{
subclassRepo.GetSubClassListAsync().Returns(
Enumerable.Range(10, 50).Select(n => new SubClass { Number = n, Name = "sub-" + n }).ToList());
subclassRepo.GetClass(Arg.Any<int>()).Returns("");
fxConverter.ConvertToChf(Arg.Any<decimal>(), Arg.Any<string>())
.Returns(ci => ci.ArgAt<decimal>(0));
}
private ViewService BuildService() =>
new(accountRepo, bankTransactionRepo, transactionRepo, subclassRepo, bookingRuleRepo, fxConverter, NullLogger<ViewService>.Instance);
// --- Balance math ---------------------------------------------------------
[Fact]
public async Task 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.GetAccountListAsync().Returns(new List<Account> { cash, receivables, payables });
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>());
var view = await BuildService().GetBalanceViewAsync();
view.ActivaAccountList.Count.ShouldBe(2);
view.PassivaAccountList.Count.ShouldBe(1);
view.TotalActivaCHF.ShouldBe(150m);
view.TotalPassivaCHF.ShouldBe(200m);
}
[Fact]
public async Task ApplyTransactionsToStartBalanceWhenComputingAccountBalance()
{
var cash = Account("cash", 1000, "CHF", startBalance: 1000m);
var groceries = Account("groc", 4000, "CHF");
accountRepo.GetAccountListAsync().Returns(new List<Account> { cash, groceries });
transactionRepo.GetTransactionListAsync().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 = await BuildService().GetAccountViewListAsync();
accounts.Single(a => a.Id == "cash").Balance.ShouldBe(920m);
accounts.Single(a => a.Id == "groc").Balance.ShouldBe(80m);
}
[Fact]
public async Task ConvertBalanceToChfWhenAccountUsesForeignCurrency()
{
var usd = Account("usd", 1050, "USD", startBalance: 100m);
accountRepo.GetAccountListAsync().Returns(new List<Account> { usd });
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>());
fxConverter.ConvertToChf(Arg.Any<decimal>(), "USD")
.Returns(ci => ci.ArgAt<decimal>(0) * 0.9m);
var account = (await BuildService().GetAccountViewListAsync()).Single();
account.Balance.ShouldBe(100m);
account.BalanceCHF.ShouldBe(90m);
account.StartBalanceCHF.ShouldBe(90m);
}
// --- Bank-transaction auto-matching --------------------------------------
[Fact]
public async Task MatchBankTransactionByBookingRuleWhenLookupTextIsContained()
{
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var utilities = Account("util", 4200, "CHF");
accountRepo.GetAccountListAsync().Returns(new List<Account> { checking, utilities });
bookingRuleRepo.GetBookingRuleListAsync().Returns(new List<BookingRule>
{
new() { Id = "r1", LookupText = "electric", AccountId = "util", BookingText = "Strom" }
});
bankTransactionRepo.GetBankTransactionListAsync().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.GetTransactionListAsync().Returns(new List<Transaction>());
var matched = (await BuildService().MatchOpenBankTransactionsAsync()).Single();
matched.Text.ShouldBe("Strom");
matched.OriginAccountId.ShouldBe("chk");
matched.TargetAccountId.ShouldBe("util");
}
[Fact]
public async Task PreferBookingRuleWithMatchingValueWhenMultipleRulesMatchText()
{
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var rent = Account("rent", 4100, "CHF");
var groceries = Account("groc", 4200, "CHF");
accountRepo.GetAccountListAsync().Returns(new List<Account> { checking, rent, groceries });
bookingRuleRepo.GetBookingRuleListAsync().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.GetBankTransactionListAsync().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.GetTransactionListAsync().Returns(new List<Transaction>());
var matched = (await BuildService().MatchOpenBankTransactionsAsync()).Single();
matched.Text.ShouldBe("Rent via Coop");
matched.TargetAccountId.ShouldBe("rent");
}
[Fact]
public async Task FallBackToSameAccountsLastMonthWhenNoBookingRuleMatches()
{
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var salary = Account("sal", 3000, "CHF");
accountRepo.GetAccountListAsync().Returns(new List<Account> { checking, salary });
bookingRuleRepo.GetBookingRuleListAsync().Returns(new List<BookingRule>());
bankTransactionRepo.GetBankTransactionListAsync().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) }
});
transactionRepo.GetTransactionListAsync().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 = (await BuildService().MatchOpenBankTransactionsAsync()).Single();
matched.Text.ShouldBe("Monatslohn");
}
[Fact]
public async Task ExcludeIgnoredAndAlreadyBookedWhenGettingOpenBankTransactions()
{
bankTransactionRepo.GetBankTransactionListAsync().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.GetTransactionListAsync().Returns(new List<Transaction>
{
new() { Id = "t1", BankTransactionId = "booked", OriginAccountId = "a", TargetAccountId = "b", Value = 10 }
});
var open = await BuildService().GetOpenBankTransactionListAsync();
open.Count.ShouldBe(1);
open[0].Id.ShouldBe("open");
}
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,6 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@inject NavigationManager Navigation
@implements IDisposable
<FluentLayout> <FluentLayout>
<FluentHeader Class="hidePrint"> <FluentHeader Class="hidePrint">
@ -9,7 +11,24 @@
<FluentBodyContent Class="body-content"> <FluentBodyContent Class="body-content">
<FluentMessageBarProvider Section="MESSAGES_TOP" @rendermode="InteractiveServer" /> <FluentMessageBarProvider Section="MESSAGES_TOP" @rendermode="InteractiveServer" />
<div class="content"> <div class="content">
@Body <ErrorBoundary @ref="errorBoundary">
<ChildContent>
@Body
</ChildContent>
<ErrorContent>
<FluentStack Orientation="Orientation.Vertical" Style="padding: 2rem; max-width: 640px;">
<FluentMessageBar Intent="MessageIntent.Error"
Title="Ein Fehler ist aufgetreten"
AllowDismiss="false">
Diese Seite konnte nicht angezeigt werden. Details stehen im Server-Log.
</FluentMessageBar>
<FluentStack Orientation="Orientation.Horizontal">
<FluentButton Appearance="Appearance.Neutral" OnClick="Recover">Neu versuchen</FluentButton>
<FluentAnchor Href="/" Appearance="Appearance.Neutral">Zur Startseite</FluentAnchor>
</FluentStack>
</FluentStack>
</ErrorContent>
</ErrorBoundary>
</div> </div>
</FluentBodyContent> </FluentBodyContent>
</FluentStack> </FluentStack>
@ -29,3 +48,22 @@
<a href="." class="reload">Reload</a> <a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span> <span class="dismiss">🗙</span>
</div> </div>
@code {
private ErrorBoundary? errorBoundary;
protected override void OnInitialized()
{
// ErrorBoundary state persists across NavigationManager.NavigateTo, so the
// error page would linger even after the user clicked "Zur Startseite".
// Reset on any location change.
Navigation.LocationChanged += OnLocationChanged;
}
private void OnLocationChanged(object? sender, Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs e)
=> errorBoundary?.Recover();
private void Recover() => errorBoundary?.Recover();
public void Dispose() => Navigation.LocationChanged -= OnLocationChanged;
}

View file

@ -0,0 +1,9 @@
@* Consistent placeholder for async-loaded pages/dialogs. Replaces the
ad-hoc `<p>Lädt…</p>` sprinkled across pages after PR P made every
OnInitializedAsync truly async. Kept as a pure visual — callers keep
their existing null-guard and just render this while data is loading. *@
<div style="display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 2rem;">
<FluentProgressRing/>
<FluentLabel>Lädt…</FluentLabel>
</div>

View file

@ -3,10 +3,13 @@
<PageTitle>Konten</PageTitle> <PageTitle>Konten</PageTitle>
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neues Konto hinzufügen</FluentButton> <FluentStack Orientation="Orientation.Horizontal" VerticalAlignment="VerticalAlignment.Center">
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neues Konto hinzufügen</FluentButton>
<FluentSearch @bind-Value="filter" Immediate="true" Placeholder="Filter…" Style="width: 300px;"/>
</FluentStack>
<FluentPaginator State="@pagination" SummaryTemplate="@template" /> <FluentPaginator State="@pagination" SummaryTemplate="@template" />
<FluentDataGrid Items="@accountQueryable" Pagination="@pagination" RowSize="DataGridRowSize.Medium"> <FluentDataGrid Items="@Filtered" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
<PropertyColumn Property="@(p => p.Class)" Sortable="true" Width="100px" /> <PropertyColumn Property="@(p => p.Class)" Sortable="true" Width="100px" />
<PropertyColumn Property="@(p => p.SubClass)" Sortable="true" Width="140px" /> <PropertyColumn Property="@(p => p.SubClass)" Sortable="true" Width="140px" />
<PropertyColumn Property="@(p => p.Number)" Sortable="true" IsDefaultSortColumn="true" Width="140px" /> <PropertyColumn Property="@(p => p.Number)" Sortable="true" IsDefaultSortColumn="true" Width="140px" />
@ -16,10 +19,10 @@
<PropertyColumn Property="@(p => p.StartBalance)" Format="C2"/> <PropertyColumn Property="@(p => p.StartBalance)" Format="C2"/>
<PropertyColumn Property="@(p => p.LastBankBalance)" Format="C2" /> <PropertyColumn Property="@(p => p.LastBankBalance)" Format="C2" />
<TemplateColumn Align="Align.Center" Width="50px"> <TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" OnClick="@(() => DeleteAsync(context.Id))" /> <FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" title="Löschen" aria-label="Löschen" OnClick="@(() => DeleteAsync(context.Id))" />
</TemplateColumn> </TemplateColumn>
<TemplateColumn Align="Align.Center" Width="50px"> <TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" OnClick="@(() => EditAsync(context.Id))" /> <FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" title="Bearbeiten" aria-label="Bearbeiten" OnClick="@(() => EditAsync(context.Id))" />
</TemplateColumn> </TemplateColumn>
</FluentDataGrid> </FluentDataGrid>

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -8,18 +8,30 @@ namespace Schaad.Accounting.UI.Components.Pages;
public partial class Accounts : ComponentBase public partial class Accounts : ComponentBase
{ {
[Inject] [Inject]
private IAccountRepository accountRepository { get; set; } = null!; private IAccountRepository accountRepository { get; set; } = null!;
[Inject] [Inject]
private IDialogService dialogService { get; set; } = null!; private IDialogService dialogService { get; set; } = null!;
private IQueryable<Account>? accountQueryable; [Inject]
private IToastService toastService { get; set; } = null!;
protected override Task OnInitializedAsync() private IReadOnlyList<Account> source = [];
private string filter = "";
private IQueryable<Account> Filtered =>
string.IsNullOrWhiteSpace(filter)
? source.AsQueryable()
: source.AsQueryable().Where(a =>
a.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
a.Number.ToString().Contains(filter) ||
(a.BankAccountNumber != null && a.BankAccountNumber.Contains(filter, StringComparison.OrdinalIgnoreCase)));
protected override async Task OnInitializedAsync()
{ {
accountQueryable = accountRepository.GetAccountList().AsQueryable(); source = await accountRepository.GetAccountListAsync();
return base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
private async Task AddAsync() private async Task AddAsync()
@ -36,13 +48,14 @@ public partial class Accounts : ComponentBase
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled && result.Data != null) if (!result.Cancelled && result.Data != null)
{ {
accountQueryable = accountRepository.GetAccountList().AsQueryable(); source = await accountRepository.GetAccountListAsync();
toastService.ShowToast(ToastIntent.Success, "Konto gespeichert", 3000);
} }
} }
private async Task EditAsync(string id) private async Task EditAsync(string id)
{ {
var data = accountRepository.GetAccount(id); var data = await accountRepository.GetAccountAsync(id);
var dialog = await dialogService.ShowDialogAsync<AccountDialog>(data, new DialogParameters() var dialog = await dialogService.ShowDialogAsync<AccountDialog>(data, new DialogParameters()
{ {
Height = "500px", Height = "500px",
@ -54,19 +67,21 @@ public partial class Accounts : ComponentBase
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled && result.Data != null) if (!result.Cancelled && result.Data != null)
{ {
accountQueryable = accountRepository.GetAccountList().AsQueryable(); source = await accountRepository.GetAccountListAsync();
toastService.ShowToast(ToastIntent.Success, "Konto gespeichert", 3000);
} }
} }
private async Task DeleteAsync(string id) private async Task DeleteAsync(string id)
{ {
var account = accountRepository.GetAccount(id); var account = await accountRepository.GetAccountAsync(id);
var dialog = await dialogService.ShowConfirmationAsync($"Konto '{account.Name}' wirklich löschen?", "Ja", "Nein", "Konto löschen"); var dialog = await dialogService.ShowConfirmationAsync($"Konto '{account.Name}' wirklich löschen?", "Ja", "Nein", "Konto löschen");
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled) if (!result.Cancelled)
{ {
accountRepository.DeleteAccount(id); await accountRepository.DeleteAccountAsync(id);
accountQueryable = accountRepository.GetAccountList().AsQueryable(); source = await accountRepository.GetAccountListAsync();
toastService.ShowToast(ToastIntent.Success, "Konto gelöscht", 3000);
} }
} }
} }

View file

@ -1,10 +1,11 @@
@page "/banktransactions" @page "/banktransactions"
<PageTitle>BankTransactions</PageTitle> <PageTitle>BankTransactions</PageTitle>
<FluentSearch @bind-Value="filter" Immediate="true" Placeholder="Filter…" Style="width: 300px;"/>
<FluentPaginator State="@pagination" /> <FluentPaginator State="@pagination" />
<div id="datagrid-container"> <div id="datagrid-container">
<FluentDataGrid Items="@TransactionList" Pagination="@pagination" RowSize="DataGridRowSize.Small" AutoItemsPerPage="true" Style="overflow-y:hidden;"> <FluentDataGrid Items="@Filtered" Pagination="@pagination" RowSize="DataGridRowSize.Small" AutoItemsPerPage="true" Style="overflow-y:hidden;">
<PropertyColumn Title="Buchungsdatum" Width="170px" Property="@(p => p.BookingDate)" Format="dd.MM.yyyy" Sortable="true" IsDefaultSortColumn="true" InitialSortDirection="SortDirection.Descending" /> <PropertyColumn Title="Buchungsdatum" Width="170px" Property="@(p => p.BookingDate)" Format="dd.MM.yyyy" Sortable="true" IsDefaultSortColumn="true" InitialSortDirection="SortDirection.Descending" />
<PropertyColumn Title="Valutadatum" Width="170px" Property="@(p => p.ValueDate)" Format="dd.MM.yyyy" Sortable="true" /> <PropertyColumn Title="Valutadatum" Width="170px" Property="@(p => p.ValueDate)" Format="dd.MM.yyyy" Sortable="true" />
<PropertyColumn Title="Text" Property="@(p => p.Text)" /> <PropertyColumn Title="Text" Property="@(p => p.Text)" />

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -8,11 +8,22 @@ public partial class BankTransactions : ComponentBase
{ {
[Inject] [Inject]
private IBankTransactionRepository bankTransactionRepository { get; set; } = null!; private IBankTransactionRepository bankTransactionRepository { get; set; } = null!;
private IQueryable<BankTransaction>? TransactionList;
protected override Task OnInitializedAsync() private IReadOnlyList<BankTransaction> source = [];
private string filter = "";
private IQueryable<BankTransaction> Filtered =>
string.IsNullOrWhiteSpace(filter)
? source.AsQueryable()
: source.AsQueryable().Where(t =>
(t.Text != null && t.Text.Contains(filter, StringComparison.OrdinalIgnoreCase)) ||
(t.Debtor != null && t.Debtor.Contains(filter, StringComparison.OrdinalIgnoreCase)) ||
(t.Creditor != null && t.Creditor.Contains(filter, StringComparison.OrdinalIgnoreCase)) ||
t.Value.ToString().Contains(filter));
protected override async Task OnInitializedAsync()
{ {
TransactionList = bankTransactionRepository.GetBankTransactionList().AsQueryable(); source = await bankTransactionRepository.GetBankTransactionListAsync();
return base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
} }

View file

@ -3,19 +3,22 @@
<PageTitle>Buchungsregeln</PageTitle> <PageTitle>Buchungsregeln</PageTitle>
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neue Regel hinzufügen</FluentButton> <FluentStack Orientation="Orientation.Horizontal" VerticalAlignment="VerticalAlignment.Center">
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neue Regel hinzufügen</FluentButton>
<FluentSearch @bind-Value="filter" Immediate="true" Placeholder="Filter…" Style="width: 300px;"/>
</FluentStack>
<FluentPaginator State="@pagination" SummaryTemplate="@template" /> <FluentPaginator State="@pagination" SummaryTemplate="@template" />
<FluentDataGrid Items="@bookingRulesQueryable" Pagination="@pagination" RowSize="DataGridRowSize.Medium"> <FluentDataGrid Items="@Filtered" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
<PropertyColumn Property="@(p => p.LookupText)" Sortable="true" IsDefaultSortColumn="true" /> <PropertyColumn Property="@(p => p.LookupText)" Sortable="true" IsDefaultSortColumn="true" />
<PropertyColumn Property="@(p => p.LookupValue)" Format="C2" Align="Align.End"/> <PropertyColumn Property="@(p => p.LookupValue)" Format="C2" Align="Align.End"/>
<PropertyColumn Property="@(p => p.BookingText)" /> <PropertyColumn Property="@(p => p.BookingText)" />
<PropertyColumn Property="@(p => p.Account)" /> <PropertyColumn Property="@(p => p.Account)" />
<TemplateColumn Align="Align.Center" Width="50px"> <TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" OnClick="@(() => DeleteAsync(context.Id))" /> <FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" title="Löschen" aria-label="Löschen" OnClick="@(() => DeleteAsync(context.Id))" />
</TemplateColumn> </TemplateColumn>
<TemplateColumn Align="Align.Center" Width="50px"> <TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" OnClick="@(() => EditAsync(context.Id))" /> <FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" title="Bearbeiten" aria-label="Bearbeiten" OnClick="@(() => EditAsync(context.Id))" />
</TemplateColumn> </TemplateColumn>
</FluentDataGrid> </FluentDataGrid>

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Datasets; using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
@ -18,12 +18,24 @@ public partial class BookingRules : ComponentBase
[Inject] [Inject]
private IDialogService dialogService { get; set; } = null!; private IDialogService dialogService { get; set; } = null!;
private IQueryable<BookingRuleDataset>? bookingRulesQueryable; [Inject]
private IToastService toastService { get; set; } = null!;
protected override Task OnInitializedAsync() private IReadOnlyList<BookingRuleDataset> source = [];
private string filter = "";
private IQueryable<BookingRuleDataset> Filtered =>
string.IsNullOrWhiteSpace(filter)
? source.AsQueryable()
: source.AsQueryable().Where(r =>
r.LookupText.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
(r.BookingText != null && r.BookingText.Contains(filter, StringComparison.OrdinalIgnoreCase)) ||
(r.Account != null && r.Account.Contains(filter, StringComparison.OrdinalIgnoreCase)));
protected override async Task OnInitializedAsync()
{ {
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable(); source = await viewService.GetBookingRuleViewListAsync();
return base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
private async Task AddAsync() private async Task AddAsync()
@ -40,13 +52,14 @@ public partial class BookingRules : ComponentBase
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled && result.Data != null) if (!result.Cancelled && result.Data != null)
{ {
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable(); source = await viewService.GetBookingRuleViewListAsync();
toastService.ShowToast(ToastIntent.Success, "Buchungsregel gespeichert", 3000);
} }
} }
private async Task EditAsync(string id) private async Task EditAsync(string id)
{ {
var data = bookingRuleRepository.GetBookingRule(id); var data = await bookingRuleRepository.GetBookingRuleAsync(id);
var dialog = await dialogService.ShowDialogAsync<BookingRuleDialog>(data, new DialogParameters() var dialog = await dialogService.ShowDialogAsync<BookingRuleDialog>(data, new DialogParameters()
{ {
Height = "500px", Height = "500px",
@ -58,19 +71,21 @@ public partial class BookingRules : ComponentBase
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled && result.Data != null) if (!result.Cancelled && result.Data != null)
{ {
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable(); source = await viewService.GetBookingRuleViewListAsync();
toastService.ShowToast(ToastIntent.Success, "Buchungsregel gespeichert", 3000);
} }
} }
private async Task DeleteAsync(string id) private async Task DeleteAsync(string id)
{ {
var bookingRule = bookingRuleRepository.GetBookingRule(id); var bookingRule = await bookingRuleRepository.GetBookingRuleAsync(id);
var dialog = await dialogService.ShowConfirmationAsync($"Regel '{bookingRule.LookupText}' wirklich löschen?", "Ja", "Nein", "Buchungsregel löschen"); var dialog = await dialogService.ShowConfirmationAsync($"Regel '{bookingRule.LookupText}' wirklich löschen?", "Ja", "Nein", "Buchungsregel löschen");
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled) if (!result.Cancelled)
{ {
bookingRuleRepository.DeleteBookingRule(id); await bookingRuleRepository.DeleteBookingRuleAsync(id);
bookingRulesQueryable = viewService.GetBookingRuleViewList().AsQueryable(); source = await viewService.GetBookingRuleViewListAsync();
toastService.ShowToast(ToastIntent.Success, "Buchungsregel gelöscht", 3000);
} }
} }
} }

View file

@ -3,16 +3,19 @@
<PageTitle>Buchungstexte</PageTitle> <PageTitle>Buchungstexte</PageTitle>
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neuer Text hinzufügen</FluentButton> <FluentStack Orientation="Orientation.Horizontal" VerticalAlignment="VerticalAlignment.Center">
<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" Appearance="Appearance.Neutral" OnClick="AddAsync">Neuer Text hinzufügen</FluentButton>
<FluentSearch @bind-Value="filter" Immediate="true" Placeholder="Filter…" Style="width: 300px;"/>
</FluentStack>
<FluentPaginator State="@pagination" SummaryTemplate="@template" /> <FluentPaginator State="@pagination" SummaryTemplate="@template" />
<FluentDataGrid Items="@bookingTextQueryable" Pagination="@pagination" RowSize="DataGridRowSize.Medium"> <FluentDataGrid Items="@Filtered" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
<PropertyColumn Property="@(p => p.Text)" Sortable="true" IsDefaultSortColumn="true" /> <PropertyColumn Property="@(p => p.Text)" Sortable="true" IsDefaultSortColumn="true" />
<TemplateColumn Align="Align.Center" Width="50px"> <TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" OnClick="@(() => DeleteAsync(context.Id))" /> <FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" title="Löschen" aria-label="Löschen" OnClick="@(() => DeleteAsync(context.Id))" />
</TemplateColumn> </TemplateColumn>
<TemplateColumn Align="Align.Center" Width="50px"> <TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" OnClick="@(() => EditAsync(context.Id))" /> <FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" title="Bearbeiten" aria-label="Bearbeiten" OnClick="@(() => EditAsync(context.Id))" />
</TemplateColumn> </TemplateColumn>
</FluentDataGrid> </FluentDataGrid>

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -14,12 +14,21 @@ public partial class BookingTexts : ComponentBase
[Inject] [Inject]
private IDialogService dialogService { get; set; } = null!; private IDialogService dialogService { get; set; } = null!;
private IQueryable<BookingText>? bookingTextQueryable; [Inject]
private IToastService toastService { get; set; } = null!;
protected override Task OnInitializedAsync() private IReadOnlyList<BookingText> source = [];
private string filter = "";
private IQueryable<BookingText> Filtered =>
string.IsNullOrWhiteSpace(filter)
? source.AsQueryable()
: source.AsQueryable().Where(t => t.Text.Contains(filter, StringComparison.OrdinalIgnoreCase));
protected override async Task OnInitializedAsync()
{ {
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable(); source = await bookingTextRepository.GetBookingTextListAsync();
return base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
private async Task AddAsync() private async Task AddAsync()
@ -36,13 +45,14 @@ public partial class BookingTexts : ComponentBase
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled && result.Data != null) if (!result.Cancelled && result.Data != null)
{ {
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable(); source = await bookingTextRepository.GetBookingTextListAsync();
toastService.ShowToast(ToastIntent.Success, "Buchungstext gespeichert", 3000);
} }
} }
private async Task EditAsync(string id) private async Task EditAsync(string id)
{ {
var data = bookingTextRepository.GetBookingText(id); var data = await bookingTextRepository.GetBookingTextAsync(id);
var dialog = await dialogService.ShowDialogAsync<BookingTextDialog>(data, new DialogParameters() var dialog = await dialogService.ShowDialogAsync<BookingTextDialog>(data, new DialogParameters()
{ {
Height = "500px", Height = "500px",
@ -54,19 +64,21 @@ public partial class BookingTexts : ComponentBase
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled && result.Data != null) if (!result.Cancelled && result.Data != null)
{ {
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable(); source = await bookingTextRepository.GetBookingTextListAsync();
toastService.ShowToast(ToastIntent.Success, "Buchungstext gespeichert", 3000);
} }
} }
private async Task DeleteAsync(string id) private async Task DeleteAsync(string id)
{ {
var bookingText = bookingTextRepository.GetBookingText(id); var bookingText = await bookingTextRepository.GetBookingTextAsync(id);
var dialog = await dialogService.ShowConfirmationAsync($"Buchungstext '{bookingText.Text}' wirklich löschen?", "Ja", "Nein", "Buchungstext löschen"); var dialog = await dialogService.ShowConfirmationAsync($"Buchungstext '{bookingText.Text}' wirklich löschen?", "Ja", "Nein", "Buchungstext löschen");
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled) if (!result.Cancelled)
{ {
bookingTextRepository.DeleteBookingText(id); await bookingTextRepository.DeleteBookingTextAsync(id);
bookingTextQueryable = bookingTextRepository.GetBookingTextList().AsQueryable(); source = await bookingTextRepository.GetBookingTextListAsync();
toastService.ShowToast(ToastIntent.Success, "Buchungstext gelöscht", 3000);
} }
} }
} }

View file

@ -1,6 +1,15 @@
@page "/Assets" @page "/Assets"
@rendermode InteractiveServer @rendermode InteractiveServer
<PageTitle>Ausgaben</PageTitle> <PageTitle>Vermögen</PageTitle>
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" /> <FluentCard Style="padding: 16px; max-width: 900px;">
@if (config is not null && layout is not null && data is not null)
{
<PlotlyChart style="height: 560px; min-height: 420px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
}
else
{
<Loading/>
}
</FluentCard>

View file

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Plotly.Blazor; using Plotly.Blazor;
using Plotly.Blazor.Traces; using Plotly.Blazor.Traces;
using Schaad.Accounting.Datasets.Charts;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
namespace Schaad.Accounting.UI.Components.Pages.Charts; namespace Schaad.Accounting.UI.Components.Pages.Charts;
@ -8,48 +9,26 @@ namespace Schaad.Accounting.UI.Components.Pages.Charts;
public partial class Assets : ComponentBase public partial class Assets : ComponentBase
{ {
private PlotlyChart chart = null!; private PlotlyChart chart = null!;
private Config config = null!; private Config? config;
private Plotly.Blazor.Layout layout = null!; private Plotly.Blazor.Layout? layout;
private IList<ITrace> data = null!; private IList<ITrace>? data;
[Inject] [Inject]
private IViewService viewService { get; set; } = null!; private IViewService viewService { get; set; } = null!;
protected override Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
config = new Config config = ChartTheme.BaseConfig();
{ layout = ChartTheme.BaseLayout("Vermögen nach Konto");
Responsive = true
};
layout = new Plotly.Blazor.Layout var accounts = (await viewService.GetAccountViewListAsync()).Where(a => a.Class == ClassIds.Activa);
{ var totals = accounts
}; .Where(a => a.BalanceCHF > 0)
.Select(a => new CategoryTotal(a.Name, a.BalanceCHF))
.ToList();
var accounts = viewService.GetAccountViewList().Where(a => a.Class == ClassIds.Activa); data = new List<ITrace> { ChartTheme.BuildDonutTrace(ChartFormatting.FoldTail(totals)) };
var values = new List<object>();
var labels = new List<object>();
var ids = new List<object>();
foreach (var account in accounts)
{
if (account.BalanceCHF > 0)
{
values.Add(account.BalanceCHF);
labels.Add(account.Name);
ids.Add(account.Id);
}
}
data = new List<ITrace> await base.OnInitializedAsync();
{
new Pie
{
Values = values,
Labels = labels,
Ids = ids
}
};
return base.OnInitializedAsync();
} }
} }

View file

@ -0,0 +1,10 @@
@if (config is not null && layout is not null && data is not null)
{
<FluentCard Style="padding: 16px;">
<PlotlyChart style="height: 400px; min-height: 300px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
</FluentCard>
}
else
{
<Loading/>
}

View file

@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Components;
using Plotly.Blazor;
using Plotly.Blazor.Traces;
using Schaad.Accounting.Interfaces;
namespace Schaad.Accounting.UI.Components.Pages.Charts;
public partial class BookingTextBreakdown : ComponentBase
{
private PlotlyChart chart = null!;
private Config? config;
private Plotly.Blazor.Layout? layout;
private IList<ITrace>? data;
[Parameter, EditorRequired]
public string AccountId { get; set; } = null!;
[Inject]
private IChartService chartService { get; set; } = null!;
protected override async Task OnInitializedAsync()
{
config = ChartTheme.BaseConfig();
layout = ChartTheme.BaseLayout("Nach Buchungstext");
var totals = await chartService.GetBookingTextTotalsAsync(AccountId);
data = new List<ITrace> { ChartTheme.BuildDonutTrace(ChartFormatting.FoldTail(totals)) };
}
}

View file

@ -0,0 +1,10 @@
@if (config is not null && layout is not null)
{
<FluentCard Style="padding: 16px;">
<PlotlyChart style="height: 400px; min-height: 300px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
</FluentCard>
}
else
{
<Loading/>
}

View file

@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Components;
using Plotly.Blazor;
using Plotly.Blazor.LayoutLib;
using Plotly.Blazor.Traces;
using Schaad.Accounting.Interfaces;
namespace Schaad.Accounting.UI.Components.Pages.Charts;
public partial class BookingTextOverTime : ComponentBase
{
private PlotlyChart chart = null!;
private Config? config;
private Plotly.Blazor.Layout? layout;
private IList<ITrace> data = new List<ITrace>();
[Parameter, EditorRequired]
public string AccountId { get; set; } = null!;
[Inject]
private IChartService chartService { get; set; } = null!;
protected override async Task OnInitializedAsync()
{
config = ChartTheme.BaseConfig();
layout = ChartTheme.BaseLayout("Nach Buchungstext im Verlauf");
layout.BarMode = BarModeEnum.Stack;
layout.BarCornerRadius = 4;
layout.HoverMode = HoverModeEnum.XUnified;
layout.XAxis = new List<XAxis>
{
new()
{
Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Category,
GridColor = ChartTheme.GridColor,
LineColor = ChartTheme.GridColor
}
};
layout.YAxis = new List<YAxis>
{
new()
{
Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = "CHF" },
GridColor = ChartTheme.GridColor,
ZeroLineColor = ChartTheme.GridColor,
SeparateThousands = true
}
};
var dataSeries = await chartService.GetBookingTextExpensesPerMonthAsync(AccountId);
data = ChartTheme.BuildStackedBarTraces(ChartFormatting.FoldTail(dataSeries));
await base.OnInitializedAsync();
}
}

View file

@ -0,0 +1,72 @@
using Schaad.Accounting;
using Schaad.Accounting.Datasets.Charts;
namespace Schaad.Accounting.UI.Components.Pages.Charts;
/// <summary>
/// Swiss (de-CH) formatting for chart labels/tooltips, plus the top-N + "Andere"
/// folding used to keep pies and stacked series legible. Plotly's own d3-format
/// tokens can't produce the apostrophe thousands separator, so values are
/// pre-formatted here in C# and passed to Plotly as literal text.
/// </summary>
public static class ChartFormatting
{
private static readonly string[] MonthAbbreviations =
[
"Jan", "Feb", "Mrz", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"
];
public static string MonthLabel(DateOnly date) => $"{MonthAbbreviations[date.Month - 1]} {date.Year % 100:00}";
public static string Chf(decimal value) => $"{value.ToFormattedString()} CHF";
/// <summary>
/// Sorts by magnitude descending and, past <see cref="ChartTheme.MaxCategories"/>,
/// collapses the remainder into a single "Andere" entry so a pie/legend never
/// has to carry more slices than the categorical palette can distinguish.
/// </summary>
public static IReadOnlyList<CategoryTotal> FoldTail(IReadOnlyList<CategoryTotal> items)
{
var sorted = items.OrderByDescending(i => Math.Abs(i.Value)).ToList();
if (sorted.Count <= ChartTheme.MaxCategories)
{
return sorted;
}
var head = sorted.Take(ChartTheme.MaxCategories - 1).ToList();
var tailTotal = sorted.Skip(ChartTheme.MaxCategories - 1).Sum(i => i.Value);
head.Add(new CategoryTotal(ChartTheme.OtherLabel, tailTotal));
return head;
}
/// <summary>
/// Same idea as <see cref="FoldTail(IReadOnlyList{CategoryTotal})"/> for a set of
/// monthly series (e.g. a stacked bar chart): series are ranked by total magnitude
/// across all months, and everything past the cap is summed month-by-month into
/// a single "Andere" series.
/// </summary>
public static IReadOnlyList<DataSerie> FoldTail(IReadOnlyList<DataSerie> series)
{
if (series.Count <= ChartTheme.MaxCategories)
{
return series;
}
var ranked = series.OrderByDescending(s => s.Y.Sum(Math.Abs)).ToList();
var head = ranked.Take(ChartTheme.MaxCategories - 1).ToList();
var tail = ranked.Skip(ChartTheme.MaxCategories - 1).ToList();
var x = tail[0].X;
var otherY = new decimal[x.Count];
foreach (var serie in tail)
{
for (var i = 0; i < x.Count; i++)
{
otherY[i] += serie.Y[i];
}
}
head.Add(new DataSerie(ChartTheme.OtherLabel, ChartTheme.OtherLabel, x, otherY));
return head;
}
}

View file

@ -0,0 +1,144 @@
using Plotly.Blazor;
using Plotly.Blazor.LayoutLib;
using Plotly.Blazor.Traces;
using Schaad.Accounting.Datasets.Charts;
namespace Schaad.Accounting.UI.Components.Pages.Charts;
/// <summary>
/// Shared look for every chart: a fixed-order, colour-vision-safe categorical
/// palette plus Fluent-matching chrome. The palette is the validated default
/// from the project's data-viz guidelines (scripts/validate_palette.js) - do
/// not add or reorder hues without re-running the validator.
/// </summary>
public static class ChartTheme
{
public static readonly string[] Categorical =
[
"#2a78d6", // blue
"#eb6834", // orange
"#1baf7a", // aqua
"#eda100", // yellow
"#e87ba4", // magenta
"#008300", // green
"#4a3aa7", // violet
"#e34948" // red
];
/// <summary>Beyond this many categories, fold the tail into a single "Andere" bucket.</summary>
public const int MaxCategories = 7;
public const string OtherLabel = "Andere";
// Muted ink, not a series hue - "Andere" must never look like a real category.
public const string OtherColor = "#898781";
public const string GridColor = "#e1e0d9";
public const string PrimaryInk = "#0b0b0b";
public const string MutedInk = "#52514e";
public const string PlotSurface = "#fcfcfb";
public const string FontFamily = "'Helvetica Neue', Helvetica, Arial, sans-serif";
public static string ColorAt(int index) => index < Categorical.Length ? Categorical[index] : OtherColor;
public static Config BaseConfig() => new()
{
Responsive = true,
DisplayLogo = false
};
public static Plotly.Blazor.Layout BaseLayout(string? title = null)
{
var layout = new Plotly.Blazor.Layout
{
Font = new Font { Family = FontFamily, Color = PrimaryInk, Size = 12 },
PaperBgColor = PlotSurface,
PlotBgColor = PlotSurface,
Margin = new Margin { L = 48, R = 24, T = string.IsNullOrEmpty(title) ? 16 : 48, B = 40, Pad = 4 },
Legend = new List<Legend>
{
new()
{
Font = new() { Family = FontFamily, Color = MutedInk, Size = 12 },
BgColor = "rgba(0,0,0,0)"
}
},
HoverLabel = new()
{
BgColor = "#ffffff",
BorderColor = GridColor,
Font = new() { Family = FontFamily, Color = PrimaryInk, Size = 12 }
}
};
if (!string.IsNullOrEmpty(title))
{
layout.Title = new Title { Text = title, Font = new() { Family = FontFamily, Color = PrimaryInk, Size = 15 } };
}
return layout;
}
/// <summary>
/// A donut trace for a set of already-folded (<see cref="ChartFormatting.FoldTail(IReadOnlyList{CategoryTotal})"/>)
/// categories: fixed-order palette, CH-formatted hover text, and direct labels
/// outside the slice (values are magnitudes - a slice can't represent a sign).
/// </summary>
public static Pie BuildDonutTrace(IReadOnlyList<CategoryTotal> items)
{
var labels = new List<object>();
var values = new List<object>();
var text = new List<object>();
var colors = new List<object>();
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
labels.Add(item.Name);
values.Add(Math.Abs(item.Value));
text.Add(ChartFormatting.Chf(item.Value));
colors.Add(item.Name == OtherLabel ? OtherColor : ColorAt(i));
}
return new Pie
{
Labels = labels,
Values = values,
Text = text,
Sort = false,
Hole = 0.55m,
Marker = new() { Colors = colors, Line = new() { Color = PlotSurface, Width = 2 } },
TextInfo = Plotly.Blazor.Traces.PieLib.TextInfoFlag.Label,
TextPosition = Plotly.Blazor.Traces.PieLib.TextPositionEnum.Outside,
OutsideTextFont = new() { Family = FontFamily, Color = MutedInk, Size = 12 },
HoverTemplate = "<b>%{label}</b><br>%{text}<extra></extra>"
};
}
/// <summary>
/// One stacked-bar trace per already-folded (<see cref="ChartFormatting.FoldTail(IReadOnlyList{DataSerie})"/>)
/// series: fixed-order palette, a hairline surface gap between stacked segments,
/// CH month labels on the x-axis, and CH-formatted hover text.
/// </summary>
public static List<ITrace> BuildStackedBarTraces(IReadOnlyList<DataSerie> series)
{
var traces = new List<ITrace>();
for (var i = 0; i < series.Count; i++)
{
var serie = series[i];
var color = serie.Name == OtherLabel ? OtherColor : ColorAt(i);
traces.Add(new Bar
{
Name = serie.Name,
X = serie.X.Select(d => (object)ChartFormatting.MonthLabel(d)).ToList(),
Y = serie.Y.Select(v => (object)v).ToList(),
TextArray = serie.Y.Select(ChartFormatting.Chf).ToList(),
Marker = new() { Color = color, Line = new() { Color = PlotSurface, Width = 2 } },
HoverTemplate = "%{fullData.name}: %{text}<extra></extra>"
});
}
return traces;
}
}

View file

@ -1,6 +1,15 @@
@page "/spendings" @page "/spendings"
@rendermode InteractiveServer @rendermode InteractiveServer
<PageTitle>Ausgaben</PageTitle> <PageTitle>Ausgaben</PageTitle>
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" /> <FluentCard Style="padding: 16px; max-width: 900px;">
@if (config is not null && layout is not null && data is not null)
{
<PlotlyChart style="height: 560px; min-height: 420px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
}
else
{
<Loading/>
}
</FluentCard>

View file

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Plotly.Blazor; using Plotly.Blazor;
using Plotly.Blazor.Traces; using Plotly.Blazor.Traces;
using Schaad.Accounting.Datasets.Charts;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
namespace Schaad.Accounting.UI.Components.Pages.Charts; namespace Schaad.Accounting.UI.Components.Pages.Charts;
@ -8,45 +9,27 @@ namespace Schaad.Accounting.UI.Components.Pages.Charts;
public partial class Spendings : ComponentBase public partial class Spendings : ComponentBase
{ {
private PlotlyChart chart = null!; private PlotlyChart chart = null!;
private Config config = null!; private Config? config;
private Plotly.Blazor.Layout layout = null!; private Plotly.Blazor.Layout? layout;
private IList<ITrace> data = null!; private IList<ITrace>? data;
[Inject] [Inject]
private IViewService viewService { get; set; } = null!; private IViewService viewService { get; set; } = null!;
protected override Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
config = new Config config = ChartTheme.BaseConfig();
{ layout = ChartTheme.BaseLayout("Ausgaben nach Kategorie");
Responsive = true
};
layout = new Plotly.Blazor.Layout var accounts = (await viewService.GetAccountViewListAsync()).Where(a => a.Class == ClassIds.Expenses);
{ var totals = accounts
}; .GroupBy(a => a.SubClass)
.Select(g => new CategoryTotal(g.First().SubClassName, g.Sum(a => a.Balance)))
.Where(t => t.Value != 0)
.ToList();
var accounts = viewService.GetAccountViewList().Where(a => a.Class == ClassIds.Expenses); data = new List<ITrace> { ChartTheme.BuildDonutTrace(ChartFormatting.FoldTail(totals)) };
var values = new List<object>();
var labels = new List<object>();
foreach (var grp in accounts.GroupBy(a => a.SubClass).Select(a => new {Key = a.Key, List = a.ToList()}))
{
if (grp.List.Sum(g => g.Balance) != 0)
{
values.Add(grp.List.Sum(g => g.Balance));
labels.Add(grp.List.First().SubClassName);
}
}
data = new List<ITrace> await base.OnInitializedAsync();
{
new Pie
{
Values =values,
Labels = labels
}
};
return base.OnInitializedAsync();
} }
} }

View file

@ -1,6 +1,15 @@
@page "/SpendingsOverTime" @page "/SpendingsOverTime"
@rendermode InteractiveServer @rendermode InteractiveServer
<PageTitle>Ausgaben im Verlauf</PageTitle> <PageTitle>Ausgaben im Verlauf</PageTitle>
<PlotlyChart style="height: 700px; min-height: 500px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" /> <FluentCard Style="padding: 16px;">
@if (config is not null && layout is not null)
{
<PlotlyChart style="height: 560px; min-height: 420px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
}
else
{
<Loading/>
}
</FluentCard>

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Plotly.Blazor; using Plotly.Blazor;
using Plotly.Blazor.LayoutLib; using Plotly.Blazor.LayoutLib;
using Plotly.Blazor.Traces; using Plotly.Blazor.Traces;
@ -9,52 +9,44 @@ namespace Schaad.Accounting.UI.Components.Pages.Charts;
public partial class SpendingsOverTime : ComponentBase public partial class SpendingsOverTime : ComponentBase
{ {
private PlotlyChart chart = null!; private PlotlyChart chart = null!;
private Config config = null!; private Config? config;
private Plotly.Blazor.Layout layout = null!; private Plotly.Blazor.Layout? layout;
private IList<ITrace> data = new List<ITrace>(); private IList<ITrace> data = new List<ITrace>();
[Inject] [Inject]
private IChartService chartService { get; set; } = null!; private IChartService chartService { get; set; } = null!;
protected override Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
config = new Config config = ChartTheme.BaseConfig();
{
Responsive = true
};
layout = new Plotly.Blazor.Layout layout = ChartTheme.BaseLayout("Ausgaben im Verlauf");
layout.BarMode = BarModeEnum.Stack;
layout.BarCornerRadius = 4;
layout.HoverMode = HoverModeEnum.XUnified;
layout.XAxis = new List<XAxis>
{ {
Title = new Title { Text = "Ausgaben im Verlauf" }, new()
BarMode = BarModeEnum.Stack,
YAxis = new List<YAxis>
{ {
new() Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Category,
{ GridColor = ChartTheme.GridColor,
Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = "CHF" } LineColor = ChartTheme.GridColor
} }
}, };
XAxis = new List<XAxis> layout.YAxis = new List<YAxis>
{
new()
{ {
new() Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = "CHF" },
{ GridColor = ChartTheme.GridColor,
Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Date ZeroLineColor = ChartTheme.GridColor,
} SeparateThousands = true
} }
}; };
var dataSeries = await chartService.GetExpensesPerMonthAsync();
data = ChartTheme.BuildStackedBarTraces(ChartFormatting.FoldTail(dataSeries));
var dataSeries = chartService.GetExpensesPerMonth(); await base.OnInitializedAsync();
foreach (var dataSerie in dataSeries)
{
data.Add(new Bar
{
Name = dataSerie.Name,
X = dataSerie.X.Select(d => (object)d).ToList(),
Y = dataSerie.Y.Select(d => (object)d).ToList(),
});
};
return base.OnInitializedAsync();
} }
} }

View file

@ -10,10 +10,10 @@
<PropertyColumn Property="@(p => p.Number)" Sortable="true" IsDefaultSortColumn="true" Width="200px" /> <PropertyColumn Property="@(p => p.Number)" Sortable="true" IsDefaultSortColumn="true" Width="200px" />
<PropertyColumn Property="@(p => p.Name)" Sortable="true" /> <PropertyColumn Property="@(p => p.Name)" Sortable="true" />
<TemplateColumn Align="Align.Center" Width="50px"> <TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" OnClick="@(() => DeleteAsync(context.Id))" /> <FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" title="Löschen" aria-label="Löschen" OnClick="@(() => DeleteAsync(context.Id))" />
</TemplateColumn> </TemplateColumn>
<TemplateColumn Align="Align.Center" Width="50px"> <TemplateColumn Align="Align.Center" Width="50px">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" OnClick="@(() => EditAsync(context.Id))" /> <FluentButton IconEnd="@(new Icons.Regular.Size16.Edit())" Appearance="Appearance.Neutral" title="Bearbeiten" aria-label="Bearbeiten" OnClick="@(() => EditAsync(context.Id))" />
</TemplateColumn> </TemplateColumn>
</FluentDataGrid> </FluentDataGrid>

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Interfaces; using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models; using Schaad.Accounting.Models;
@ -14,12 +14,15 @@ public partial class Classes : ComponentBase
[Inject] [Inject]
private IDialogService dialogService { get; set; } = null!; private IDialogService dialogService { get; set; } = null!;
[Inject]
private IToastService toastService { get; set; } = null!;
private IQueryable<SubClass>? subclassQueryable; private IQueryable<SubClass>? subclassQueryable;
protected override Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable(); subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
return base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
private async Task AddAsync() private async Task AddAsync()
@ -36,13 +39,14 @@ public partial class Classes : ComponentBase
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled && result.Data != null) if (!result.Cancelled && result.Data != null)
{ {
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable(); subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
toastService.ShowToast(ToastIntent.Success, "Klasse gespeichert", 3000);
} }
} }
private async Task EditAsync(string id) private async Task EditAsync(string id)
{ {
var data = subclassRepository.GetSubClass(id); var data = await subclassRepository.GetSubClassAsync(id);
var dialog = await dialogService.ShowDialogAsync<ClassDialog>(data, new DialogParameters() var dialog = await dialogService.ShowDialogAsync<ClassDialog>(data, new DialogParameters()
{ {
Height = "500px", Height = "500px",
@ -54,19 +58,21 @@ public partial class Classes : ComponentBase
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled && result.Data != null) if (!result.Cancelled && result.Data != null)
{ {
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable(); subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
toastService.ShowToast(ToastIntent.Success, "Klasse gespeichert", 3000);
} }
} }
private async Task DeleteAsync(string id) private async Task DeleteAsync(string id)
{ {
var subclass = subclassRepository.GetSubClass(id); var subclass = await subclassRepository.GetSubClassAsync(id);
var dialog = await dialogService.ShowConfirmationAsync($"Klasse '{subclass.Name}' wirklich löschen?", "Ja", "Nein", "Klasse löschen"); var dialog = await dialogService.ShowConfirmationAsync($"Klasse '{subclass.Name}' wirklich löschen?", "Ja", "Nein", "Klasse löschen");
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Cancelled) if (!result.Cancelled)
{ {
subclassRepository.DeleteSubClass(id); await subclassRepository.DeleteSubClassAsync(id);
subclassQueryable = subclassRepository.GetSubClassList().AsQueryable(); subclassQueryable = (await subclassRepository.GetSubClassListAsync()).AsQueryable();
toastService.ShowToast(ToastIntent.Success, "Klasse gelöscht", 3000);
} }
} }
} }

View file

@ -14,7 +14,11 @@ public partial class AccountSelector : ComponentBase
private Account? SelectedAccount { get; set; } private Account? SelectedAccount { get; set; }
protected override void OnInitialized() // OnParametersSet, not OnInitialized: OnInitialized fires once, but callers now
// pass an empty Accounts list on first render and hydrate it after an await.
// OnParametersSet re-runs the lookup whenever Accounts or AccountId change, so the
// preselection tracks late-arriving data.
protected override void OnParametersSet()
{ {
SelectedAccount = Accounts.FirstOrDefault(account => account.Id == AccountId); SelectedAccount = Accounts.FirstOrDefault(account => account.Id == AccountId);
} }

View file

@ -28,7 +28,7 @@ public partial class AccountDialog : ComponentBase
{ {
if (editContext.Validate()) if (editContext.Validate())
{ {
accountRepository.SaveAccount(Content); await accountRepository.SaveAccountAsync(Content);
await Dialog.CloseAsync(Content); await Dialog.CloseAsync(Content);
} }
} }

View file

@ -12,6 +12,12 @@
</FluentDialogHeader> </FluentDialogHeader>
<FluentDialogBody> <FluentDialogBody>
@if (editContext is null)
{
<Loading/>
return;
}
<EditForm EditContext="@editContext"> <EditForm EditContext="@editContext">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<FluentTextField @bind-Value="@Content.LookupText" Label="Suchtext" Style="width: 300px;"/> <FluentTextField @bind-Value="@Content.LookupText" Label="Suchtext" Style="width: 300px;"/>

View file

@ -8,7 +8,7 @@ namespace Schaad.Accounting.UI.Components.Pages.Dialogs;
public partial class BookingRuleDialog : ComponentBase public partial class BookingRuleDialog : ComponentBase
{ {
private EditContext editContext = null!; private EditContext? editContext;
[CascadingParameter] [CascadingParameter]
public FluentDialog Dialog { get; set; } = null!; public FluentDialog Dialog { get; set; } = null!;
@ -24,17 +24,17 @@ public partial class BookingRuleDialog : ComponentBase
private IReadOnlyList<Account> accounts = []; private IReadOnlyList<Account> accounts = [];
protected override void OnInitialized() protected override async Task OnInitializedAsync()
{ {
editContext = new EditContext(Content); editContext = new EditContext(Content);
accounts = viewService.GetAccountViewList(); accounts = await viewService.GetAccountViewListAsync();
} }
private async Task SaveAsync() private async Task SaveAsync()
{ {
if (editContext.Validate()) if (editContext.Validate())
{ {
bookingRuleRepository.SaveBookingRule(Content); await bookingRuleRepository.SaveBookingRuleAsync(Content);
await Dialog.CloseAsync(Content); await Dialog.CloseAsync(Content);
} }
} }

View file

@ -28,7 +28,7 @@ public partial class BookingTextDialog : ComponentBase
{ {
if (editContext.Validate()) if (editContext.Validate())
{ {
bookingTextRepository.SaveBookingText(Content); await bookingTextRepository.SaveBookingTextAsync(Content);
await Dialog.CloseAsync(Content); await Dialog.CloseAsync(Content);
} }
} }

View file

@ -28,7 +28,7 @@ public partial class ClassDialog : ComponentBase
{ {
if (editContext.Validate()) if (editContext.Validate())
{ {
bookingRuleRepository.SaveSubClass(Content); await bookingRuleRepository.SaveSubClassAsync(Content);
await Dialog.CloseAsync(Content); await Dialog.CloseAsync(Content);
} }
} }

View file

@ -1,5 +1,4 @@
@using Schaad.Accounting.Models @implements IDialogContentComponent<Schaad.Accounting.Models.Transaction>
@implements IDialogContentComponent<Schaad.Accounting.Models.Transaction>
@rendermode InteractiveServer @rendermode InteractiveServer
<FluentDialogHeader ShowDismiss="true"> <FluentDialogHeader ShowDismiss="true">
@ -12,6 +11,12 @@
</FluentDialogHeader> </FluentDialogHeader>
<FluentDialogBody> <FluentDialogBody>
@if (editContext is null)
{
<Loading/>
return;
}
<EditForm EditContext="@editContext"> <EditForm EditContext="@editContext">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<FluentDatePicker @bind-Value="@SelectedValue" Label="Valuta" Style="width: 300px;"/> <FluentDatePicker @bind-Value="@SelectedValue" Label="Valuta" Style="width: 300px;"/>
@ -37,6 +42,7 @@
@bind-Value="@Content.TargetAccountId" /> @bind-Value="@Content.TargetAccountId" />
<FluentNumberField @bind-Value="@Content.Value" Label="Betrag" Style="width: 300px;"/> <FluentNumberField @bind-Value="@Content.Value" Label="Betrag" Style="width: 300px;"/>
<FluentTextField @bind-Value="@Content.Text" Label="Buchungstext" Style="width: 300px;" /> <FluentTextField @bind-Value="@Content.Text" Label="Buchungstext" Style="width: 300px;" />
<FluentNumberField @bind-Value="@Content.FxRate" Label="Kurs" Style="width: 100px;" />
<div> <div>
<FluentValidationSummary /> <FluentValidationSummary />
@ -50,6 +56,7 @@
Abbrechen Abbrechen
</FluentButton> </FluentButton>
<FluentButton Appearance="Appearance.Accent" <FluentButton Appearance="Appearance.Accent"
Disabled="@(SelectedValue is null)"
OnClick="@SaveAsync"> OnClick="@SaveAsync">
Speichern Speichern
</FluentButton> </FluentButton>

View file

@ -8,7 +8,7 @@ namespace Schaad.Accounting.UI.Components.Pages.Dialogs;
public partial class TransactionDialog : ComponentBase public partial class TransactionDialog : ComponentBase
{ {
private EditContext editContext = null!; private EditContext? editContext;
[CascadingParameter] [CascadingParameter]
public FluentDialog Dialog { get; set; } = null!; public FluentDialog Dialog { get; set; } = null!;
@ -25,20 +25,24 @@ public partial class TransactionDialog : ComponentBase
private IReadOnlyList<Account> accounts = []; private IReadOnlyList<Account> accounts = [];
private DateTime? SelectedValue; private DateTime? SelectedValue;
protected override void OnInitialized() protected override async Task OnInitializedAsync()
{ {
SelectedValue = Content.ValueDate; SelectedValue = Content.ValueDate;
editContext = new EditContext(Content); editContext = new EditContext(Content);
accounts = viewService.GetAccountViewList(); accounts = await viewService.GetAccountViewListAsync();
} }
private async Task SaveAsync() private async Task SaveAsync()
{ {
if (editContext is null || SelectedValue is null)
{
return;
}
if (editContext.Validate()) if (editContext.Validate())
{ {
Content.ValueDate = SelectedValue!.Value; Content.ValueDate = SelectedValue.Value;
Content.BookingDate = SelectedValue!.Value; Content.BookingDate = SelectedValue.Value;
transactionRepository.SaveTransaction(Content); await transactionRepository.SaveTransactionAsync(Content);
await Dialog.CloseAsync(Content); await Dialog.CloseAsync(Content);
} }
} }

View file

@ -11,6 +11,12 @@
</FluentDialogHeader> </FluentDialogHeader>
<FluentDialogBody> <FluentDialogBody>
@if (editContext is null)
{
<Loading/>
return;
}
Total: @Math.Abs(Content.Value) / Rest: @openAmount Total: @Math.Abs(Content.Value) / Rest: @openAmount
<FluentButton Appearance="Appearance.Neutral" IconEnd="@(new Icons.Regular.Size24.Add())" <FluentButton Appearance="Appearance.Neutral" IconEnd="@(new Icons.Regular.Size24.Add())"
OnClick="@AddTransactionAsync"> OnClick="@AddTransactionAsync">
@ -59,6 +65,7 @@
</td> </td>
<td style="align-content: center"> <td style="align-content: center">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral" <FluentButton IconEnd="@(new Icons.Regular.Size16.Delete())" Appearance="Appearance.Neutral"
title="Zeile entfernen" aria-label="Zeile entfernen"
OnClick="@(() => transactionList.Remove(transaction))"/> OnClick="@(() => transactionList.Remove(transaction))"/>
</td> </td>
</tr> </tr>
@ -67,6 +74,15 @@
</table> </table>
<div> <div>
<FluentValidationSummary/> <FluentValidationSummary/>
@if (validationErrors.Count > 0)
{
<ul class="validation-message">
@foreach (var err in validationErrors)
{
<li>@err</li>
}
</ul>
}
</div> </div>
</EditForm> </EditForm>
</FluentDialogBody> </FluentDialogBody>

View file

@ -9,7 +9,7 @@ namespace Schaad.Accounting.UI.Components.Pages.Dialogs;
public partial class TransactionSplitDialog : ComponentBase public partial class TransactionSplitDialog : ComponentBase
{ {
private EditContext editContext = null!; private EditContext? editContext;
[CascadingParameter] [CascadingParameter]
public FluentDialog Dialog { get; set; } = null!; public FluentDialog Dialog { get; set; } = null!;
@ -33,17 +33,18 @@ public partial class TransactionSplitDialog : ComponentBase
private IViewService viewService { get; set; } = null!; private IViewService viewService { get; set; } = null!;
private List<Transaction> transactionList = new(); private List<Transaction> transactionList = new();
private IReadOnlyList<AccountDataset> accounts = null!; private IReadOnlyList<AccountDataset> accounts = [];
private IReadOnlyList<string> bookingTexts = []; private IReadOnlyList<string> bookingTexts = [];
private decimal openAmount = 0; private decimal openAmount = 0;
private List<string> validationErrors = new();
protected override void OnInitialized() protected override async Task OnInitializedAsync()
{ {
accounts = viewService.GetAccountViewList(); accounts = await viewService.GetAccountViewListAsync();
bookingTexts = bookingTextRepository.GetBookingTextList().Select(b => b.Text).ToArray(); bookingTexts = (await bookingTextRepository.GetBookingTextListAsync()).Select(b => b.Text).ToArray();
var accountList = accountRepository.GetAccountList(); var accountList = await accountRepository.GetAccountListAsync();
var bankTrx = bankTransactionRepository.GetBankTransaction(Content.BankTransactionId); var bankTrx = await bankTransactionRepository.GetBankTransactionAsync(Content.BankTransactionId);
var trx = new Transaction(bankTrx, accountList); var trx = new Transaction(bankTrx, accountList);
trx.Value = Math.Abs(bankTrx.Value); trx.Value = Math.Abs(bankTrx.Value);
transactionList.Add(trx); transactionList.Add(trx);
@ -68,16 +69,43 @@ public partial class TransactionSplitDialog : ComponentBase
openAmount = Content.Value + transactionList.Sum(t => t.Value); openAmount = Content.Value + transactionList.Sum(t => t.Value);
} }
// Every row in transactionList needs Text, TargetAccountId, and a non-zero Value.
// The single EditContext above only covers the first row (that's what it was
// constructed with) — so DataAnnotations validation misses subsequent split rows.
// Run explicit per-row checks here and surface everything in validationErrors.
private bool ValidateAllRows()
{
validationErrors.Clear();
for (var i = 0; i < transactionList.Count; i++)
{
var t = transactionList[i];
if (string.IsNullOrWhiteSpace(t.Text))
{
validationErrors.Add($"Zeile {i + 1}: Buchungstext fehlt");
}
if (string.IsNullOrEmpty(t.TargetAccountId))
{
validationErrors.Add($"Zeile {i + 1}: Konto fehlt");
}
if (t.Value == 0)
{
validationErrors.Add($"Zeile {i + 1}: Betrag darf nicht 0 sein");
}
}
return validationErrors.Count == 0;
}
private async Task SaveAsync() private async Task SaveAsync()
{ {
if (editContext.Validate()) if (!ValidateAllRows())
{ {
foreach (var transtaction in transactionList) return;
{
transactionRepository.SaveTransaction(transtaction);
}
await Dialog.CloseAsync(Content);
} }
foreach (var transtaction in transactionList)
{
await transactionRepository.SaveTransactionAsync(transtaction);
}
await Dialog.CloseAsync(Content);
} }
private async Task CancelAsync() private async Task CancelAsync()

View file

@ -1,101 +1,9 @@
@page "/" @page "/"
@rendermode InteractiveServer @rendermode InteractiveServer
<PageTitle>Home</PageTitle> <PageTitle>Home</PageTitle>
<FluentInputFile @ref="@myFileByBuffer" <StatementImport OnImported="RefreshPendingAsync"/>
AnchorId="MyUploadBuffer"
DragDropZoneVisible="false"
Mode="InputFileMode.Buffer"
Multiple="true"
MaximumFileSize="@(10 * 1024 * 1024)"
Accept=".zip, .xml"
OnProgressChange="@OnProgressChangeAsync"
OnCompleted="@OnCompletedAsync"/>
<FluentProgress Visible="@(progressPercent > 0)" Min="0" Max="100" Value="@progressPercent"/> <PendingBookings @ref="pendingBookings"/>
<FluentLabel Alignment="HorizontalAlignment.Center">
@progressTitle
</FluentLabel>
<FluentButton Appearance="Appearance.Lightweight" IconStart="@(new Icons.Regular.Size16.ArrowUpload())"
Id="MyUploadBuffer">
Upload files
</FluentButton>
<FluentButton Appearance="Appearance.Lightweight" IconStart="@(new Icons.Regular.Size16.Dismiss())"
@onclick="@((e) => IsCanceled = true)">
Cancel
</FluentButton>
@if (matchingBankTransactions == null || matchingBankTransactions.Count == 0)
{
<p>Keine Transaktionen vorhanden!</p>
<FluentEmoji Value="@(new Emojis.SmileysEmotion.Color.Default.MoneyMouthFace())" />
}
else
{
<table>
<thead>
<tr>
<th>Buchungsdatum</th>
<th>Valutadatum</th>
<th>Betrag</th>
<th>Zusatzinfo</th>
<th>Text</th>
<th>Konto</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var transaction in matchingBankTransactions)
{
<tr>
<td>
<div>@transaction.BookingDate.ToString("dd.MM.yyyy")</div>
</td>
<td>
<div>@transaction.ValueDate.ToString("dd.MM.yyyy")</div>
</td>
<td class="betrag-column">
<div>@Math.Abs(transaction.Value).ToFormattedString()</div>
</td>
<td class="zusatzinfo-column" title="@transaction.BankTransactionText">
<div>@transaction.BankTransactionText</div>
</td>
<td>
<div class="text-column">
<input list="texts" @bind="@transaction.Text" placeholder="Type or select..."/>
<datalist id="texts">
@foreach (var text in bookingTexts.OrderBy(t => t))
{
<option value="@text"></option>
}
</datalist>
</div>
</td>
<td>
@if (transaction.Value > 0)
{
<AccountSelector Accounts="accounts" @bind-AccountId="@transaction.OriginAccountId" />
}
else
{
<AccountSelector Accounts="accounts" @bind-AccountId="@transaction.TargetAccountId" />
}
</td>
<td style="align-content: center">
<FluentButton IconEnd="@(new Icons.Regular.Size16.SplitVertical())" Appearance="Appearance.Neutral"
OnClick="@(() => SplitBankTransactionAsync(transaction))" Disabled="@(transaction.Value > 0)"/>
</td>
<td style="align-content: center">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Save())" Appearance="Appearance.Neutral"
OnClick="@(() => BookBankTransactionAsync(transaction))"/>
</td>
</tr>
}
</tbody>
</table>
}

View file

@ -1,197 +1,16 @@
using System.IO.Compression;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.UI.Components.Pages.Dialogs;
namespace Schaad.Accounting.UI.Components.Pages; namespace Schaad.Accounting.UI.Components.Pages;
public partial class Home : ComponentBase public partial class Home : ComponentBase
{ {
[Inject] private PendingBookings? pendingBookings;
private IViewService viewService { get; set; } = null!;
[Inject] private async Task RefreshPendingAsync()
private IToastService toastService { get; set; } = null!;
[Inject]
private IMessageService messageService { get; set; } = null!;
[Inject]
private IDialogService dialogService { get; set; } = null!;
[Inject]
private ISettingsService settingsService { get; set; } = null!;
[Inject]
private IFileService fileService { get; set; } = null!;
[Inject]
private IBookingTextRepository bookingTextRepository { get; set; } = null!;
[Inject]
private ITransactionRepository transactionRepository { get; set; } = null!;
[Inject]
private NavigationManager Navigation { get; set; } = null!;
private List<Transaction>? matchingBankTransactions;
private IReadOnlyList<Account> accounts = [];
private IReadOnlyList<string> bookingTexts = [];
FluentInputFile? myFileByBuffer;
int? progressPercent;
string? progressTitle;
bool IsCanceled;
Dictionary<int, string> Files = new();
private bool loaded;
protected override Task OnInitializedAsync()
{ {
if (loaded == false) { if (pendingBookings is not null)
matchingBankTransactions = viewService.MatchOpenBankTransactions();
accounts = viewService.GetAccountViewList();
bookingTexts = bookingTextRepository.GetBookingTextList().Select(b => b.Text).ToArray();
loaded = true;
}
return base.OnInitializedAsync();
}
async Task OnProgressChangeAsync(FluentInputFileEventArgs file)
{
progressPercent = file.ProgressPercent;
progressTitle = file.ProgressTitle;
// To cancel?
file.IsCancelled = IsCanceled;
// New file
if (!Files.ContainsKey(file.Index))
{ {
var localFile = Path.GetTempFileName() + file.Name; await pendingBookings.RefreshAsync();
Files.Add(file.Index, localFile);
} }
// Write to the FileStream
await file.Buffer.AppendToFileAsync(Files[file.Index]);
}
private async Task OnCompletedAsync(IEnumerable<FluentInputFileEventArgs> files)
{
progressPercent = myFileByBuffer!.ProgressPercent;
progressTitle = myFileByBuffer!.ProgressTitle;
foreach (var file in Files)
{
var extension = Path.GetExtension(file.Value);
if (extension is ".xml")
{
await ImportXmlAndShowResultAsync(file.Value);
}
else if (extension is ".zip")
{
var unzippedFiles = UnzipAndListFiles(file.Value);
foreach (var unzippedFile in unzippedFiles)
{
await ImportXmlAndShowResultAsync(unzippedFile);
File.Delete(unzippedFile);
}
}
File.Delete(file.Value);
}
matchingBankTransactions = viewService.MatchOpenBankTransactions();
}
private async Task ImportXmlAndShowResultAsync(string fileName)
{
var messages = fileService.ImportAccountStatementFile(fileName);
foreach (var message in messages)
{
await ShowImportResultAsync(message);
}
}
private IReadOnlyList<string> UnzipAndListFiles(string zipFilePath)
{
var tempExtractPath = Path.Combine(Path.GetTempPath(), Path.GetFileNameWithoutExtension(Path.GetRandomFileName()));
Directory.CreateDirectory(tempExtractPath);
ZipFile.ExtractToDirectory(zipFilePath, tempExtractPath);
return new List<string>(Directory.GetFiles(tempExtractPath, "*", SearchOption.AllDirectories));
}
private async Task ShowImportResultAsync(MessageDataset message)
{
var joinedDetails = string.Join(", ", message.Lines);
var text = $"{message.Title}: {joinedDetails}";
if (message.Status == MessageStatus.Success)
{
ShowToast(text, ToastIntent.Success);
}
else if (message.Status == MessageStatus.Info)
{
ShowToast(text, ToastIntent.Info);
}
else if (message.Status == MessageStatus.Warning)
{
await ShowMessageAsync(text, MessageIntent.Warning);
}
else if (message.Status == MessageStatus.Error)
{
await ShowMessageAsync(text, MessageIntent.Error);
}
}
private async Task ShowMessageAsync(string message, MessageIntent intent)
{
await messageService.ShowMessageBarAsync(message, intent, "MESSAGES_TOP");
}
private void ShowToast(string message, ToastIntent intent)
{
toastService.ShowToast(intent, message, 3000);
}
private async Task BookBankTransactionAsync(Transaction transaction)
{
if (string.IsNullOrEmpty(transaction.Text) || string.IsNullOrEmpty(transaction.TargetAccountId))
{
ShowToast("Transaktion kann wegen fehlenden Daten nicht gespeichert werden!", ToastIntent.Error);
return;
}
transactionRepository.SaveTransaction(transaction);
ReloadPage();
await Task.CompletedTask;
}
private async Task SplitBankTransactionAsync(Transaction transaction)
{
var dialog = await dialogService.ShowDialogAsync<TransactionSplitDialog>(transaction, new DialogParameters()
{
Height = "500px",
Width = "800px",
Title = "Split Transaktion",
PreventDismissOnOverlayClick = true,
PreventScroll = true,
});
var result = await dialog.Result;
if (!result.Cancelled && result.Data != null)
{
ReloadPage();
await Task.CompletedTask;
}
}
private void ReloadPage()
{
var uri = Navigation.Uri;
Navigation.NavigateTo(uri, forceLoad: true); // forceLoad = true does a full reload
} }
} }

View file

@ -16,13 +16,14 @@ th {
background-color: #f8f8f8; background-color: #f8f8f8;
} }
th:nth-child(1), td:nth-child(1) { width: 120px; } th:nth-child(1), td:nth-child(1) { width: 120px; } /* Booking Date */
th:nth-child(2), td:nth-child(2) { width: 120px; } th:nth-child(2), td:nth-child(2) { width: 120px; } /* Valuta Date */
th:nth-child(3), td:nth-child(3) { width: 100px; text-align: right; } th:nth-child(3), td:nth-child(3) { } /* Zusatzinfo */
th:nth-child(5), td:nth-child(5) { width: 240px; } th:nth-child(4), td:nth-child(4) { width: 240px; } /* Text */
th:nth-child(6), td:nth-child(6) { width: 300px; } th:nth-child(5), td:nth-child(5) { width: 300px; } /* Account */
th:nth-child(7), td:nth-child(7), th:nth-child(6), td:nth-child(6) { width: 100px; text-align: right; } /* Amount */
th:nth-child(8), td:nth-child(8) { width: 40px; text-align: center; } th:nth-child(7), td:nth-child(7) { width: 40px; text-align: center; } /* Split */
th:nth-child(8), td:nth-child(8) { width: 40px; text-align: center; } /* Save */
.text-column input { .text-column input {
width: 100%; width: 100%;
@ -36,8 +37,3 @@ th:nth-child(8), td:nth-child(8) { width: 40px; text-align: center; }
text-overflow: ellipsis; text-overflow: ellipsis;
width: 100%; width: 100%;
} }
.betrag-column {
text-align: right;
white-space: nowrap;
}

View file

@ -0,0 +1,75 @@
@rendermode InteractiveServer
@if (matchingBankTransactions is null || matchingBankTransactions.Count == 0)
{
<p>Keine Transaktionen vorhanden!</p>
<FluentEmoji Value="@(new Emojis.SmileysEmotion.Color.Default.MoneyMouthFace())" />
}
else
{
<table>
<thead>
<tr>
<th>Buchungsdatum</th>
<th>Valutadatum</th>
<th>Zusatzinfo</th>
<th>Text</th>
<th>Konto</th>
<th>Betrag</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var transaction in matchingBankTransactions)
{
<tr>
<td>
<div>@transaction.BookingDate.ToString("dd.MM.yyyy")</div>
</td>
<td>
<div>@transaction.ValueDate.ToString("dd.MM.yyyy")</div>
</td>
<td class="zusatzinfo-column" title="@transaction.BankTransactionText">
<div>@transaction.BankTransactionText</div>
</td>
<td>
<div class="text-column">
<input list="texts" @bind="@transaction.Text" placeholder="Type or select..." readonly="@transaction.ReadOnly"/>
<datalist id="texts">
@foreach (var text in bookingTexts.OrderBy(t => t))
{
<option value="@text"></option>
}
</datalist>
</div>
</td>
<td>
@if (transaction.Value > 0)
{
<AccountSelector Accounts="accounts" @bind-AccountId="@transaction.OriginAccountId" />
}
else
{
<AccountSelector Accounts="accounts" @bind-AccountId="@transaction.TargetAccountId" />
}
</td>
<td>
<div>@Math.Abs(transaction.Value).ToFormattedString()</div>
</td>
<td style="align-content: center">
<FluentButton IconEnd="@(new Icons.Regular.Size16.SplitVertical())" Appearance="Appearance.Neutral"
title="Aufteilen" aria-label="Aufteilen"
OnClick="@(() => SplitBankTransactionAsync(transaction))" Disabled="@(transaction.Value > 0 || transaction.ReadOnly)" />
</td>
<td style="align-content: center">
<FluentButton IconEnd="@(new Icons.Regular.Size16.Save())" Appearance="Appearance.Neutral"
title="Buchen" aria-label="Buchen"
OnClick="@(() => BookBankTransactionAsync(transaction))"
Disabled="@transaction.ReadOnly" />
</td>
</tr>
}
</tbody>
</table>
}

View file

@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.UI.Components.Pages.Dialogs;
namespace Schaad.Accounting.UI.Components.Pages;
public partial class PendingBookings : ComponentBase
{
[Inject]
private IViewService viewService { get; set; } = null!;
[Inject]
private ITransactionRepository transactionRepository { get; set; } = null!;
[Inject]
private IBookingTextRepository bookingTextRepository { get; set; } = null!;
[Inject]
private IDialogService dialogService { get; set; } = null!;
[Inject]
private IToastService toastService { get; set; } = null!;
private List<Transaction>? matchingBankTransactions;
private IReadOnlyList<Account> accounts = [];
private IReadOnlyList<string> bookingTexts = [];
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
await base.OnInitializedAsync();
}
/// <summary>
/// Re-run the auto-matcher and reload accounts / booking texts. Called from
/// the parent (Home) after a statement import completes.
/// </summary>
public async Task RefreshAsync()
{
await LoadDataAsync();
StateHasChanged();
}
private async Task LoadDataAsync()
{
matchingBankTransactions = await viewService.MatchOpenBankTransactionsAsync();
accounts = await viewService.GetAccountViewListAsync();
bookingTexts = (await bookingTextRepository.GetBookingTextListAsync()).Select(b => b.Text).ToArray();
}
private async Task BookBankTransactionAsync(Transaction transaction)
{
if (string.IsNullOrEmpty(transaction.Text) || string.IsNullOrEmpty(transaction.TargetAccountId))
{
toastService.ShowToast(ToastIntent.Error, "Transaktion kann wegen fehlenden Daten nicht gespeichert werden!", 3000);
return;
}
await transactionRepository.SaveTransactionAsync(transaction);
transaction.ReadOnly = true;
}
private async Task SplitBankTransactionAsync(Transaction transaction)
{
var dialog = await dialogService.ShowDialogAsync<TransactionSplitDialog>(transaction, new DialogParameters()
{
Height = "500px",
Width = "800px",
Title = "Split Transaktion",
PreventDismissOnOverlayClick = true,
PreventScroll = true,
});
var result = await dialog.Result;
if (!result.Cancelled && result.Data != null)
{
await LoadDataAsync();
}
}
}

Some files were not shown because too many files have changed in this diff Show more