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>
This commit is contained in:
Claudio Schaad 2026-07-02 21:57:17 +02:00
parent 857b312f9f
commit 3ffe4578c2
15 changed files with 116 additions and 119 deletions

View file

@ -107,3 +107,4 @@ Analysis and phased plan produced 2026-07-02. See conversation history for full
- **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 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 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 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.

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

@ -74,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

@ -28,16 +28,22 @@ namespace Schaad.Accounting.Repositories
/// </summary> /// </summary>
public void SaveAccount(Account account) public void SaveAccount(Account account)
{ {
var accounts = GetAccountList(); if (string.IsNullOrEmpty(account.Currency))
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();
var accounts = GetAccountList();
var idx = accounts.FindIndex(a => a.Id == account.Id);
if (idx >= 0)
{
accounts[idx] = account;
}
else
{
account.Id = Guid.NewGuid().ToString();
accounts.Add(account);
} }
account.Copy(existingAccount);
Save(accounts, ACCOUNTS); Save(accounts, ACCOUNTS);
} }

View file

@ -28,15 +28,16 @@ namespace Schaad.Accounting.Repositories
public void SaveBookingRule(BookingRule bookingRule) public void SaveBookingRule(BookingRule bookingRule)
{ {
var bookingRules = GetBookingRuleList(); var bookingRules = GetBookingRuleList();
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(); else
{
bookingRule.Id = Guid.NewGuid().ToString();
bookingRules.Add(bookingRule);
} }
bookingRule.Copy(existingRule);
Save(bookingRules, BOOKING_RULES); Save(bookingRules, BOOKING_RULES);
} }

View file

@ -28,15 +28,16 @@ namespace Schaad.Accounting.Repositories
public void SaveBookingText(BookingText bookingText) public void SaveBookingText(BookingText bookingText)
{ {
var bookingTexts = GetBookingTextList(); var bookingTexts = GetBookingTextList();
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(); else
{
bookingText.Id = Guid.NewGuid().ToString();
bookingTexts.Add(bookingText);
} }
bookingText.Copy(existingText);
Save(bookingTexts, BOOKING_TEXTS); Save(bookingTexts, BOOKING_TEXTS);
} }

View file

@ -28,15 +28,16 @@ namespace Schaad.Accounting.Repositories
public void SaveSplitPredefinition(SplitPredefinition splitPredefinition) public void SaveSplitPredefinition(SplitPredefinition splitPredefinition)
{ {
var definitions = GetSplitPredefinitionList(); var definitions = GetSplitPredefinitionList();
var existingDefinition = definitions.FirstOrDefault(a => a.Id == splitPredefinition.Id); var idx = definitions.FindIndex(d => d.Id == splitPredefinition.Id);
if (idx >= 0)
if (existingDefinition == null)
{ {
existingDefinition = new SplitPredefinition(); definitions[idx] = splitPredefinition;
definitions.Add(existingDefinition); }
splitPredefinition.Id = Guid.NewGuid().ToString(); else
{
splitPredefinition.Id = Guid.NewGuid().ToString();
definitions.Add(splitPredefinition);
} }
splitPredefinition.Copy(existingDefinition);
Save(definitions, SPLIT_PREDEFINITION); Save(definitions, SPLIT_PREDEFINITION);
} }
} }

View file

@ -29,20 +29,21 @@ namespace Schaad.Accounting.Repositories
public List<SubClass> GetSubClassList() => LoadList<SubClass>(SUBCLASSES); public List<SubClass> GetSubClassList() => LoadList<SubClass>(SUBCLASSES);
/// <summary> /// <summary>
/// Save a booking text (insert/update) /// Save a subclass (insert/update)
/// </summary> /// </summary>
public void SaveSubClass(SubClass subClass) public void SaveSubClass(SubClass subClass)
{ {
var subclasses = GetSubClassList(); var subclasses = GetSubClassList();
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(); else
{
subClass.Id = Guid.NewGuid().ToString();
subclasses.Add(subClass);
} }
subClass.Copy(existingSubClass);
Save(subclasses, SUBCLASSES); Save(subclasses, SUBCLASSES);
} }

View file

@ -45,17 +45,22 @@ namespace Schaad.Accounting.Repositories
transaction.FxRate = null; transaction.FxRate = null;
} }
if (transaction.BookingDate <= DateTime.MinValue)
{
transaction.BookingDate = transaction.ValueDate;
}
var transactionList = GetTransactionList(); var transactionList = GetTransactionList();
var existingTransaction = transactionList.FirstOrDefault(a => a.Id == transaction.Id); var idx = transactionList.FindIndex(t => t.Id == transaction.Id);
if (idx >= 0)
if (existingTransaction == null)
{ {
existingTransaction = new Transaction(); transactionList[idx] = transaction;
transactionList.Add(existingTransaction); }
transaction.Id = Guid.NewGuid().ToString(); else
{
transaction.Id = Guid.NewGuid().ToString();
transactionList.Add(transaction);
} }
transaction.Copy(existingTransaction);
Save(transactionList, TRANSACTIONS); Save(transactionList, TRANSACTIONS);
} }
@ -71,8 +76,7 @@ namespace Schaad.Accounting.Repositories
return null; return null;
} }
var result = new Transaction(); var result = stored.Clone();
stored.Copy(result);
// value is stored in CHF -> convert back to foreign currency for display/editing // value is stored in CHF -> convert back to foreign currency for display/editing
var isFxAccount = accountRepository.GetAccount(result.OriginAccountId).IsFxAccount var isFxAccount = accountRepository.GetAccount(result.OriginAccountId).IsFxAccount

View file

@ -159,8 +159,7 @@ namespace Schaad.Accounting.Services
var originAccount = accountsById[t.OriginAccountId]; var originAccount = accountsById[t.OriginAccountId];
if (originAccount.Class == ClassIds.Activa && accountId == t.OriginAccountId) if (originAccount.Class == ClassIds.Activa && accountId == t.OriginAccountId)
{ {
var copy = new Transaction(); var copy = t.Clone();
t.Copy(copy);
copy.Value *= -1; copy.Value *= -1;
return copy; return copy;
} }

View file

@ -136,4 +136,43 @@ public class TransactionRepositoryTestShould : IDisposable
sut.GetTransactionList().ShouldBeEmpty(); sut.GetTransactionList().ShouldBeEmpty();
} }
[Fact]
public void PreserveRelatedPartyWhenRoundTrippingTransaction()
{
accountRepo.GetAccount(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
sut.SaveTransaction(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)
});
// Regression: earlier Transaction.Copy dropped RelatedParty on every save,
// so the round-trip lost it.
sut.GetTransactionList().Single().RelatedParty.ShouldBe("ACME Property AG");
}
[Fact]
public void DefaultBookingDateToValueDateWhenBookingDateIsUnset()
{
accountRepo.GetAccount(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
sut.SaveTransaction(new Transaction
{
OriginAccountId = "a",
TargetAccountId = "b",
Value = 10m,
Text = "x",
ValueDate = new DateTime(2026, 4, 15)
// BookingDate left at default (DateTime.MinValue)
});
sut.GetTransactionList().Single().BookingDate.ShouldBe(new DateTime(2026, 4, 15));
}
} }