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>
This commit is contained in:
Claudio Schaad 2026-07-02 21:23:00 +02:00
parent 5d75bbb64d
commit 8926fb1e97
12 changed files with 916 additions and 2 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

View file

@ -11,11 +11,14 @@ dotnet build
# Run (dev server at http://localhost:5225) # Run (dev server at http://localhost:5225)
cd Schaad.Accounting.UI && dotnet run cd Schaad.Accounting.UI && dotnet run
# Test
dotnet test
# Publish # Publish
dotnet publish -c Release dotnet publish -c Release
``` ```
There are no automated tests or lint commands. Tests live in `Schaad.Accounting.Tests` (xUnit + NSubstitute + Shouldly). There is no lint command.
## Architecture ## Architecture
@ -62,3 +65,28 @@ Pages live in `Schaad.Accounting.UI/Components/Pages/`. Each page typically has
### Domain constants ### 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`). `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.

View file

@ -102,4 +102,4 @@ Analysis and phased plan produced 2026-07-02. See conversation history for full
- **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 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. - **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)** — deferred. After PR D, each XML file is loaded at most once per SignalR circuit, and this app is single-user local Blazor Server. Converting every repository/service method to async would touch ~40 files for negligible user-visible benefit and real regression risk. Revisit if the app is ever hosted for multiple concurrent users. - **Item 19 (async I/O)** — deferred. After PR D, each XML file is loaded at most once per SignalR circuit, and this app is single-user local Blazor Server. Converting every repository/service method to async would touch ~40 files for negligible user-visible benefit and real regression risk. Revisit if the app is ever hosted for multiple concurrent users.
- **PR G** — Phase 4 (tests) — locks in behavior before further invasive changes. - **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.

View file

@ -0,0 +1,112 @@
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());
}
public void Dispose()
{
if (Directory.Exists(dbDir))
{
Directory.Delete(dbDir, recursive: true);
}
}
[Fact]
public void AssignNewIdWhenSavingAccountWithoutId()
{
var account = new Account { Number = 1000, Name = "Cash", Currency = "CHF" };
sut.SaveAccount(account);
var stored = sut.GetAccountList().Single();
stored.Id.ShouldNotBeNullOrEmpty();
stored.Name.ShouldBe("Cash");
}
[Fact]
public void UpdateInPlaceWhenSavingAccountWithExistingId()
{
sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
var id = sut.GetAccountList().Single().Id;
sut.SaveAccount(new Account { Id = id, Number = 1000, Name = "Cash renamed", Currency = "CHF" });
var accounts = sut.GetAccountList();
accounts.Count.ShouldBe(1);
accounts[0].Name.ShouldBe("Cash renamed");
}
[Fact]
public void DefaultCurrencyToChfWhenSavingAccountWithoutCurrency()
{
sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = null! });
sut.GetAccountList().Single().Currency.ShouldBe("CHF");
}
[Fact]
public void RemoveAccountWhenDeletingById()
{
sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = "CHF" });
var id = sut.GetAccountList().Single().Id;
sut.DeleteAccount(id);
sut.GetAccountList().ShouldBeEmpty();
}
[Fact]
public void ReturnAccountWhenLookingUpByBankAccountNumber()
{
sut.SaveAccount(new Account { Number = 1010, Name = "Checking", Currency = "CHF", BankAccountNumber = "CH01" });
sut.SaveAccount(new Account { Number = 1020, Name = "Savings", Currency = "CHF", BankAccountNumber = "CH02" });
sut.GetAccountForBankAccountNumber("CH01").Name.ShouldBe("Checking");
sut.GetAccountForBankAccountNumber("CH02").Name.ShouldBe("Savings");
sut.GetAccountForBankAccountNumber("CH99").ShouldBeNull();
}
[Fact]
public void UpdateLastBankBalanceWhenSavingBankBalance()
{
sut.SaveAccount(new Account { Number = 1010, Name = "Checking", Currency = "CHF", BankAccountNumber = "CH01" });
sut.SaveBankAccountBalance("CH01", 1234.56m);
sut.GetAccountForBankAccountNumber("CH01").LastBankBalance.ShouldBe(1234.56m);
}
[Fact]
public void SurviveConstructorReRunWhenAccountsFileAlreadyExists()
{
sut.SaveAccount(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());
var accounts = fresh.GetAccountList();
accounts.Count.ShouldBe(1);
accounts[0].Name.ShouldBe("Cash");
}
}

View file

@ -0,0 +1,132 @@
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 void HonourSelectedYearWhenBuildingMonthlySeries()
{
settingsService.GetYear().Returns(2023);
var groceries = MakeAccount("groc", 4200);
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewList().Returns(new List<AccountDataset> { AsDataset(groceries), AsDataset(checking) });
viewService.GetTransactionViewList().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2023, 4, 5))
});
var series = BuildService().GetExpensesPerMonth();
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 void GroupExpensesByAccountWhenAllExpensesShareOneSubClass()
{
var mannenbach = MakeAccount("m1", 4000); // SubClass 40
var mannenbach2 = MakeAccount("m2", 4001); // SubClass 40 (same)
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewList().Returns(new List<AccountDataset>
{
AsDataset(mannenbach), AsDataset(mannenbach2), AsDataset(checking)
});
viewService.GetTransactionViewList().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 = BuildService().GetExpensesPerMonth();
series.Count.ShouldBe(2);
series.ShouldContain(s => s.Id == "m1");
series.ShouldContain(s => s.Id == "m2");
}
[Fact]
public void GroupExpensesBySubClassWhenMultipleSubClassesAreUsed()
{
var groceries = MakeAccount("groc", 4200); // SubClass 42
var utilities = MakeAccount("util", 4300); // SubClass 43
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewList().Returns(new List<AccountDataset>
{
AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
});
viewService.GetTransactionViewList().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 = BuildService().GetExpensesPerMonth();
series.Count.ShouldBe(2);
series.ShouldContain(s => s.Id == "42"); // sub-class number as id
series.ShouldContain(s => s.Id == "43");
}
[Fact]
public void SkipAccountsWithoutTransactionsWhenBuildingSeries()
{
var groceries = MakeAccount("groc", 4000); // has activity
var utilities = MakeAccount("util", 4001); // same subclass, no activity
var checking = MakeAccount("chk", 1010);
viewService.GetAccountViewList().Returns(new List<AccountDataset>
{
AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
});
viewService.GetTransactionViewList().Returns(new List<TransactionDataset>
{
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 1))
});
var series = BuildService().GetExpensesPerMonth();
series.Count.ShouldBe(1);
series[0].Id.ShouldBe("groc");
}
private static Account MakeAccount(string id, int number) => new()
{
Id = id,
Number = number,
Name = id,
Currency = "CHF"
};
private static AccountDataset AsDataset(Account a) =>
new(a, balance: 0, balanceCHF: 0, startBalanceCHF: 0, className: "", subClassName: "");
private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, DateTime valueDate) =>
new(
new Transaction
{
Id = Guid.NewGuid().ToString(),
OriginAccountId = origin.Id,
TargetAccountId = target.Id,
Value = value,
ValueDate = valueDate,
BookingDate = valueDate
},
origin, target);
}

View file

@ -0,0 +1,106 @@
using System.Text;
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()
{
// Registering the ISO-8859-1 code page provider so the CSV encoding call works
// in the test host (which does not include legacy encodings by default).
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
private FileService BuildService() => new(
settingsService, accountRepo, transactionRepo, bankTransactionRepo,
accountStatementService, creditCardStatementService);
[Fact]
public void EmitHeaderRowAndRunningBalanceWhenExportingTransactionsCsv()
{
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF", StartBalance = 1000m };
var groceries = new Account { Id = "groc", Number = 4200, Name = "Groceries", Currency = "CHF" };
var salary = new Account { Id = "sal", Number = 3000, Name = "Salary", Currency = "CHF" };
accountRepo.GetAccount("chk").Returns(checking);
transactionRepo.GetTransactionList().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 = BuildService().GetTransactionListCsv("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");
// Debit line (outgoing) — value in Belastung column, balance drops
lines[2].ShouldBe("05.01.2026;05.01.2026;Coop;50.00;;950.00");
// Credit line (incoming) — value in Gutschrift column, balance rises
lines[3].ShouldBe("25.01.2026;25.01.2026;Salary;;5'000.00;5'950.00");
}
[Fact]
public void OrderTransactionsByBookingDateThenValueDateThenValueWhenExporting()
{
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF", StartBalance = 0m };
accountRepo.GetAccount("chk").Returns(checking);
transactionRepo.GetTransactionList().Returns(new List<Transaction>
{
// Deliberately unordered
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(BuildService().GetTransactionListCsv("chk"));
var lines = csv.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
// Skip header + start balance rows
lines[2].ShouldContain("earlier small");
lines[3].ShouldContain("earlier large");
lines[4].ShouldContain("later");
}
[Fact]
public void NotMutateTransactionValueWhenExportingCsv()
{
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF" };
accountRepo.GetAccount("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.GetTransactionList().Returns(new List<Transaction> { outgoing });
BuildService().GetTransactionListCsv("chk");
// Regression: earlier code did `trx.Value *= -1` mid-loop.
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,58 @@
using Schaad.Accounting.Repositories;
using Shouldly;
namespace Schaad.Accounting.Tests;
public class RepositoryCacheTestShould
{
[Fact]
public void CallLoaderOnceWhenKeyIsRequestedRepeatedly()
{
var cache = new RepositoryCache();
var loaderCalls = 0;
var first = cache.GetOrLoad("key", () => { loaderCalls++; return new List<int> { 1, 2 }; });
var second = cache.GetOrLoad("key", () => { loaderCalls++; return new List<int> { 99 }; });
loaderCalls.ShouldBe(1);
second.ShouldBeSameAs(first);
second.ShouldBe(new[] { 1, 2 });
}
[Fact]
public void KeepEntriesSeparateWhenKeysDiffer()
{
var cache = new RepositoryCache();
var a = cache.GetOrLoad("a", () => new List<int> { 1 });
var b = cache.GetOrLoad("b", () => new List<int> { 2 });
b.ShouldNotBeSameAs(a);
a.ShouldBe(new[] { 1 });
b.ShouldBe(new[] { 2 });
}
[Fact]
public void ReloadFromLoaderWhenKeyIsInvalidated()
{
var cache = new RepositoryCache();
var loaderCalls = 0;
cache.GetOrLoad("k", () => { loaderCalls++; return new List<int> { 1 }; });
cache.Invalidate("k");
cache.GetOrLoad("k", () => { loaderCalls++; return new List<int> { 2 }; });
loaderCalls.ShouldBe(2);
}
[Fact]
public void MatchKeysCaseInsensitivelyWhenLookingUp()
{
var cache = new RepositoryCache();
cache.GetOrLoad("Path/To/File.xml", () => new List<int> { 1 });
var upper = cache.GetOrLoad("PATH/TO/FILE.XML", () => 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,138 @@
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);
}
public void Dispose()
{
if (Directory.Exists(dbDir))
{
Directory.Delete(dbDir, recursive: true);
}
}
[Fact]
public void PreserveValueWhenRoundTrippingNonFxTransaction()
{
accountRepo.GetAccount("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
accountRepo.GetAccount("groc").Returns(new Account { Id = "groc", Currency = "CHF" });
sut.SaveTransaction(new Transaction
{
OriginAccountId = "chk",
TargetAccountId = "groc",
Value = 42m,
Text = "Groceries",
ValueDate = new DateTime(2026, 5, 10),
BookingDate = new DateTime(2026, 5, 10)
});
var stored = sut.GetTransactionList().Single();
var loaded = sut.GetTransaction(stored.Id);
loaded.ShouldNotBeNull();
loaded.Value.ShouldBe(42m);
loaded.FxRate.ShouldBeNull(); // Non-FX transactions have FxRate cleared
}
[Fact]
public void StoreInChfAndReturnInAccountCurrencyWhenTransactionIsFx()
{
// User enters 100 USD at rate 0.9 CHF/USD -> stored as 90 CHF; read back as 100 USD.
accountRepo.GetAccount("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
accountRepo.GetAccount("usd").Returns(new Account { Id = "usd", Currency = "USD" });
sut.SaveTransaction(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 = sut.GetTransactionList().Single();
stored.Value.ShouldBe(90m); // stored in CHF
var loaded = sut.GetTransaction(stored.Id);
loaded.ShouldNotBeNull();
loaded.Value.ShouldBe(100m); // display currency
loaded.FxRate.ShouldBe(0.9m);
}
[Fact]
public void ReturnNullWhenGettingUnknownTransactionId()
{
sut.GetTransaction("no-such-id").ShouldBeNull();
}
[Fact]
public void NotMutateStoredValueWhenGettingFxTransactionTwice()
{
accountRepo.GetAccount("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
accountRepo.GetAccount("usd").Returns(new Account { Id = "usd", Currency = "USD" });
sut.SaveTransaction(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 = sut.GetTransactionList().Single();
var id = stored.Id;
// Get twice; if Get mutated the stored entity, the second call would divide by FxRate again.
var first = sut.GetTransaction(id);
var second = sut.GetTransaction(id);
first.ShouldNotBeNull();
first.Value.ShouldBe(100m);
second.ShouldNotBeNull();
second.Value.ShouldBe(100m);
}
[Fact]
public void RemoveTransactionWhenDeletingById()
{
accountRepo.GetAccount(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
sut.SaveTransaction(new Transaction { OriginAccountId = "a", TargetAccountId = "b", Value = 1m, Text = "x", ValueDate = DateTime.Today });
var id = sut.GetTransactionList().Single().Id;
sut.DeleteTransaction(id);
sut.GetTransactionList().ShouldBeEmpty();
}
}

View file

@ -0,0 +1,211 @@
using NSubstitute;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.Services;
using Schaad.Finance.Api;
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 IFxService fxService = Substitute.For<IFxService>();
private readonly ISettingsService settingsService = Substitute.For<ISettingsService>();
public ViewServiceTestShould()
{
// Cover every subclass number a test might reference (Account.SubClass = Number / 100).
subclassRepo.GetSubClassList().Returns(
Enumerable.Range(10, 50).Select(n => new SubClass { Number = n, Name = "sub-" + n }).ToList());
subclassRepo.GetClass(Arg.Any<int>()).Returns("");
settingsService.GetSettings().Returns(new SettingsDataset { DataPath = "", FixerIoApiKey = "" });
// Passthrough FX by default (CHF-only). Individual tests can override.
fxService.ConvertCurrency(Arg.Any<decimal>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(ci => ci.ArgAt<decimal>(0));
}
private ViewService BuildService() =>
new(accountRepo, bankTransactionRepo, transactionRepo, subclassRepo, bookingRuleRepo, fxService, settingsService);
// --- Balance math ---------------------------------------------------------
[Fact]
public void 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.GetAccountList().Returns(new List<Account> { cash, receivables, payables });
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
var view = BuildService().GetBalanceView();
view.ActivaAccountList.Count.ShouldBe(2);
view.PassivaAccountList.Count.ShouldBe(1);
view.TotalActivaCHF.ShouldBe(150m);
view.TotalPassivaCHF.ShouldBe(200m);
}
[Fact]
public void ApplyTransactionsToStartBalanceWhenComputingAccountBalance()
{
var cash = Account("cash", 1000, "CHF", startBalance: 1000m);
var groceries = Account("groc", 4000, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { cash, groceries });
// Debit cash 50, credit groceries 50 (spending)
transactionRepo.GetTransactionList().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 = BuildService().GetAccountViewList();
accounts.Single(a => a.Id == "cash").Balance.ShouldBe(920m); // 1000 - 80
accounts.Single(a => a.Id == "groc").Balance.ShouldBe(80m);
}
[Fact]
public void ConvertBalanceToChfWhenAccountUsesForeignCurrency()
{
var usd = Account("usd", 1050, "USD", startBalance: 100m);
accountRepo.GetAccountList().Returns(new List<Account> { usd });
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
// 1 USD = 0.90 CHF
fxService.ConvertCurrency(Arg.Any<decimal>(), "USD", "CHF", Arg.Any<string>())
.Returns(ci => ci.ArgAt<decimal>(0) * 0.9m);
var account = BuildService().GetAccountViewList().Single();
account.Balance.ShouldBe(100m); // account currency
account.BalanceCHF.ShouldBe(90m); // FX-converted
account.StartBalanceCHF.ShouldBe(90m);
}
// --- Bank-transaction auto-matching --------------------------------------
[Fact]
public void MatchBankTransactionByBookingRuleWhenLookupTextIsContained()
{
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var utilities = Account("util", 4200, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { checking, utilities });
bookingRuleRepo.GetBookingRuleList().Returns(new List<BookingRule>
{
new() { Id = "r1", LookupText = "electric", AccountId = "util", BookingText = "Strom" }
});
bankTransactionRepo.GetBankTransactionList().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.GetTransactionList().Returns(new List<Transaction>());
var matched = BuildService().MatchOpenBankTransactions().Single();
matched.Text.ShouldBe("Strom");
// negative value => bank account is origin; rule fills the target
matched.OriginAccountId.ShouldBe("chk");
matched.TargetAccountId.ShouldBe("util");
}
[Fact]
public void PreferBookingRuleWithMatchingValueWhenMultipleRulesMatchText()
{
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var rent = Account("rent", 4100, "CHF");
var groceries = Account("groc", 4200, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { checking, rent, groceries });
bookingRuleRepo.GetBookingRuleList().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.GetBankTransactionList().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.GetTransactionList().Returns(new List<Transaction>());
var matched = BuildService().MatchOpenBankTransactions().Single();
matched.Text.ShouldBe("Rent via Coop");
matched.TargetAccountId.ShouldBe("rent");
}
[Fact]
public void FallBackToSameAccountsLastMonthWhenNoBookingRuleMatches()
{
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var salary = Account("sal", 3000, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { checking, salary });
bookingRuleRepo.GetBookingRuleList().Returns(new List<BookingRule>());
bankTransactionRepo.GetBankTransactionList().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) }
});
// A prior-month booking with same accounts + value should be picked up
transactionRepo.GetTransactionList().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 = BuildService().MatchOpenBankTransactions().Single();
matched.Text.ShouldBe("Monatslohn");
}
[Fact]
public void ExcludeIgnoredAndAlreadyBookedWhenGettingOpenBankTransactions()
{
bankTransactionRepo.GetBankTransactionList().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.GetTransactionList().Returns(new List<Transaction>
{
new() { Id = "t1", BankTransactionId = "booked", OriginAccountId = "a", TargetAccountId = "b", Value = 10 }
});
var open = BuildService().GetOpenBankTransactionList();
open.Count.ShouldBe(1);
open[0].Id.ShouldBe("open");
}
// --- Helpers -------------------------------------------------------------
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
};
}