diff --git a/src/Duempelkas.Infrastructure/Migrations/20260403093901_AllowSharedDisplayIdForTransfers.Designer.cs b/src/Duempelkas.Infrastructure/Migrations/20260403093901_AllowSharedDisplayIdForTransfers.Designer.cs new file mode 100644 index 0000000..8d5b132 --- /dev/null +++ b/src/Duempelkas.Infrastructure/Migrations/20260403093901_AllowSharedDisplayIdForTransfers.Designer.cs @@ -0,0 +1,169 @@ +// +using System; +using Duempelkas.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Duempelkas.Infrastructure.Migrations +{ + [DbContext(typeof(FinanceDbContext))] + [Migration("20260403093901_AllowSharedDisplayIdForTransfers")] + partial class AllowSharedDisplayIdForTransfers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("Duempelkas.Domain.Entities.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CarryoverBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Duempelkas.Domain.Entities.Entry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DisplayId") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TransferLinkId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayId"); + + b.HasIndex("TransferLinkId"); + + b.HasIndex("AccountId", "Date"); + + b.ToTable("Entries"); + }); + + modelBuilder.Entity("Duempelkas.Domain.Entities.TransferLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SourceEntryId") + .HasColumnType("INTEGER"); + + b.Property("TargetEntryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SourceEntryId") + .IsUnique(); + + b.HasIndex("TargetEntryId") + .IsUnique(); + + b.ToTable("TransferLinks"); + }); + + modelBuilder.Entity("Duempelkas.Domain.Entities.Entry", b => + { + b.HasOne("Duempelkas.Domain.Entities.Account", "Account") + .WithMany("Entries") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Duempelkas.Domain.Entities.TransferLink", "TransferLink") + .WithMany() + .HasForeignKey("TransferLinkId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("TransferLink"); + }); + + modelBuilder.Entity("Duempelkas.Domain.Entities.TransferLink", b => + { + b.HasOne("Duempelkas.Domain.Entities.Entry", "SourceEntry") + .WithMany() + .HasForeignKey("SourceEntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Duempelkas.Domain.Entities.Entry", "TargetEntry") + .WithMany() + .HasForeignKey("TargetEntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("SourceEntry"); + + b.Navigation("TargetEntry"); + }); + + modelBuilder.Entity("Duempelkas.Domain.Entities.Account", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Duempelkas.Infrastructure/Migrations/20260403093901_AllowSharedDisplayIdForTransfers.cs b/src/Duempelkas.Infrastructure/Migrations/20260403093901_AllowSharedDisplayIdForTransfers.cs new file mode 100644 index 0000000..b9f3d3a --- /dev/null +++ b/src/Duempelkas.Infrastructure/Migrations/20260403093901_AllowSharedDisplayIdForTransfers.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Duempelkas.Infrastructure.Migrations +{ + /// + public partial class AllowSharedDisplayIdForTransfers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Entries_DisplayId", + table: "Entries"); + + migrationBuilder.CreateIndex( + name: "IX_Entries_DisplayId", + table: "Entries", + column: "DisplayId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Entries_DisplayId", + table: "Entries"); + + migrationBuilder.CreateIndex( + name: "IX_Entries_DisplayId", + table: "Entries", + column: "DisplayId", + unique: true); + } + } +} diff --git a/src/Duempelkas.Infrastructure/Migrations/FinanceDbContextModelSnapshot.cs b/src/Duempelkas.Infrastructure/Migrations/FinanceDbContextModelSnapshot.cs index a6f67c5..54b156a 100644 --- a/src/Duempelkas.Infrastructure/Migrations/FinanceDbContextModelSnapshot.cs +++ b/src/Duempelkas.Infrastructure/Migrations/FinanceDbContextModelSnapshot.cs @@ -80,8 +80,7 @@ namespace Duempelkas.Infrastructure.Migrations b.HasKey("Id"); - b.HasIndex("DisplayId") - .IsUnique(); + b.HasIndex("DisplayId"); b.HasIndex("TransferLinkId"); diff --git a/src/Duempelkas.Infrastructure/Persistence/Configurations/EntryConfiguration.cs b/src/Duempelkas.Infrastructure/Persistence/Configurations/EntryConfiguration.cs index c4a9675..66bfd0f 100644 --- a/src/Duempelkas.Infrastructure/Persistence/Configurations/EntryConfiguration.cs +++ b/src/Duempelkas.Infrastructure/Persistence/Configurations/EntryConfiguration.cs @@ -10,7 +10,7 @@ public class EntryConfiguration : IEntityTypeConfiguration { builder.HasKey(e => e.Id); builder.Property(e => e.DisplayId).IsRequired().HasMaxLength(20); - builder.HasIndex(e => e.DisplayId).IsUnique(); + builder.HasIndex(e => e.DisplayId); builder.Property(e => e.Title).IsRequired().HasMaxLength(500); builder.Property(e => e.Amount).HasColumnType("decimal(18,2)"); builder.Property(e => e.Type).HasConversion(); diff --git a/src/Duempelkas.Infrastructure/Services/EntryService.cs b/src/Duempelkas.Infrastructure/Services/EntryService.cs index f690c9d..2960a62 100644 --- a/src/Duempelkas.Infrastructure/Services/EntryService.cs +++ b/src/Duempelkas.Infrastructure/Services/EntryService.cs @@ -63,7 +63,7 @@ public class EntryService : IEntryService { await using var db = await _dbFactory.CreateDbContextAsync(); - var displayId = await GenerateDisplayIdAsync(db, accountId, date.Year); + var displayId = await GenerateDisplayIdAsync(db, date.Year); var entry = new Entry { @@ -88,12 +88,12 @@ public class EntryService : IEntryService await using var db = await _dbFactory.CreateDbContextAsync(); await using var transaction = await db.Database.BeginTransactionAsync(); - var sourceDisplayId = await GenerateDisplayIdAsync(db, sourceAccountId, date.Year); + var transferDisplayId = await GenerateDisplayIdAsync(db, date.Year); var sourceEntry = new Entry { AccountId = sourceAccountId, - DisplayId = sourceDisplayId, + DisplayId = transferDisplayId, Type = EntryType.Expense, Date = date, Title = title, @@ -103,12 +103,10 @@ public class EntryService : IEntryService db.Entries.Add(sourceEntry); await db.SaveChangesAsync(); - var targetDisplayId = await GenerateDisplayIdAsync(db, targetAccountId, date.Year); - var targetEntry = new Entry { AccountId = targetAccountId, - DisplayId = targetDisplayId, + DisplayId = transferDisplayId, Type = EntryType.Income, Date = date, Title = title, @@ -234,13 +232,12 @@ public class EntryService : IEntryService if (otherEntry.AccountId != newLinkedAccountId) { otherEntry.AccountId = newLinkedAccountId; - otherEntry.DisplayId = await GenerateDisplayIdAsync(db, newLinkedAccountId, date.Year); } await db.SaveChangesAsync(); } - private static async Task GenerateDisplayIdAsync(FinanceDbContext db, int accountId, int year) + private static async Task GenerateDisplayIdAsync(FinanceDbContext db, int year) { var prefix = $"{year}-"; var maxDisplayId = await db.Entries diff --git a/tests/Duempelkas.Tests/EntryServiceBookingTests.cs b/tests/Duempelkas.Tests/EntryServiceBookingTests.cs new file mode 100644 index 0000000..34494e4 --- /dev/null +++ b/tests/Duempelkas.Tests/EntryServiceBookingTests.cs @@ -0,0 +1,123 @@ +using Duempelkas.Domain.Entities; +using Duempelkas.Domain.Enums; +using Duempelkas.Infrastructure.Persistence; +using Duempelkas.Infrastructure.Services; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Duempelkas.Tests; + +public class EntryServiceBookingTests : IDisposable +{ + private readonly FinanceDbContext _db; + private readonly EntryService _entryService; + private readonly string _connectionString = $"Data Source=duempelkas-entry-tests-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; + + public EntryServiceBookingTests() + { + var options = new DbContextOptionsBuilder() + .UseSqlite(_connectionString) + .Options; + + _db = new FinanceDbContext(options); + _db.Database.OpenConnection(); + _db.Database.EnsureCreated(); + + var dbFactory = new TestDbContextFactory(options); + _entryService = new EntryService(dbFactory); + } + + [Fact] + public async Task CreateEntry_AssignsSequentialDisplayIds_PerYear() + { + var accountA = new Account { Name = "Konto A" }; + var accountB = new Account { Name = "Konto B" }; + _db.Accounts.AddRange(accountA, accountB); + await _db.SaveChangesAsync(); + + var first = await _entryService.CreateEntryAsync(accountA.Id, EntryType.Income, new DateTime(2026, 1, 10), "Einnahme A", 100m); + var second = await _entryService.CreateEntryAsync(accountB.Id, EntryType.Expense, new DateTime(2026, 1, 11), "Ausgabe B", 20m); + var third = await _entryService.CreateEntryAsync(accountA.Id, EntryType.Income, new DateTime(2027, 1, 1), "Neues Jahr", 5m); + + first.DisplayId.Should().Be("2026-001"); + second.DisplayId.Should().Be("2026-002"); + third.DisplayId.Should().Be("2027-001"); + } + + [Fact] + public async Task CreateEntry_AfterTransfer_UsesNextDisplayId() + { + var source = new Account { Name = "Barkasse" }; + var target = new Account { Name = "Girokonto" }; + _db.Accounts.AddRange(source, target); + await _db.SaveChangesAsync(); + + await _entryService.CreateTransferAsync(source.Id, target.Id, new DateTime(2026, 3, 15), "Umbuchung", 500m); + var booking = await _entryService.CreateEntryAsync(source.Id, EntryType.Income, new DateTime(2026, 3, 16), "Einzahlung", 50m); + + booking.DisplayId.Should().Be("2026-002"); + } + + [Fact] + public async Task UpdateEntry_UpdatesBookingFields() + { + var account = new Account { Name = "Konto" }; + _db.Accounts.Add(account); + await _db.SaveChangesAsync(); + + var entry = await _entryService.CreateEntryAsync(account.Id, EntryType.Expense, new DateTime(2026, 2, 2), "Alt", 10m); + + await _entryService.UpdateEntryAsync(entry.Id, new DateTime(2026, 2, 3), "Neu", 25m); + + _db.ChangeTracker.Clear(); + var updated = await _db.Entries.SingleAsync(e => e.Id == entry.Id); + + updated.Date.Should().Be(new DateTime(2026, 2, 3)); + updated.Title.Should().Be("Neu"); + updated.Amount.Should().Be(25m); + } + + [Fact] + public async Task DeleteAndRestoreEntry_TogglesSoftDelete_ForBookingOnly() + { + var account = new Account { Name = "Konto" }; + _db.Accounts.Add(account); + await _db.SaveChangesAsync(); + + var entry = await _entryService.CreateEntryAsync(account.Id, EntryType.Expense, new DateTime(2026, 2, 2), "Buchung", 10m); + + await _entryService.DeleteEntryAsync(entry.Id); + + _db.ChangeTracker.Clear(); + var deleted = await _db.Entries.SingleAsync(e => e.Id == entry.Id); + deleted.IsDeleted.Should().BeTrue(); + + await _entryService.RestoreEntryAsync(entry.Id); + + _db.ChangeTracker.Clear(); + var restored = await _db.Entries.SingleAsync(e => e.Id == entry.Id); + restored.IsDeleted.Should().BeFalse(); + } + + public void Dispose() + { + _db.Database.CloseConnection(); + _db.Dispose(); + } + + private sealed class TestDbContextFactory : IDbContextFactory + { + private readonly DbContextOptions _options; + + public TestDbContextFactory(DbContextOptions options) + { + _options = options; + } + + public FinanceDbContext CreateDbContext() => new(_options); + + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new FinanceDbContext(_options)); + } +} diff --git a/tests/Duempelkas.Tests/TransferServiceTests.cs b/tests/Duempelkas.Tests/TransferServiceTests.cs index 0ad0c1f..835535e 100644 --- a/tests/Duempelkas.Tests/TransferServiceTests.cs +++ b/tests/Duempelkas.Tests/TransferServiceTests.cs @@ -13,11 +13,12 @@ public class TransferServiceTests : IDisposable private readonly FinanceDbContext _db; private readonly EntryService _entryService; private readonly BalanceQueryService _balanceQueryService; + private readonly string _connectionString = $"Data Source=duempelkas-transfer-tests-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; public TransferServiceTests() { var options = new DbContextOptionsBuilder() - .UseSqlite("Data Source=duempelkas-transfer-tests;Mode=Memory;Cache=Shared") + .UseSqlite(_connectionString) .Options; _db = new FinanceDbContext(options); @@ -52,7 +53,8 @@ public class TransferServiceTests : IDisposable var targetEntry = entries.Single(e => e.AccountId == accountB.Id); targetEntry.Type.Should().Be(EntryType.Income); targetEntry.Amount.Should().Be(100.00m); - targetEntry.DisplayId.Should().Be("2026-002"); + targetEntry.DisplayId.Should().Be("2026-001"); + targetEntry.DisplayId.Should().Be(sourceEntry.DisplayId); var links = await _db.TransferLinks.ToListAsync(); links.Should().HaveCount(1); @@ -105,6 +107,29 @@ public class TransferServiceTests : IDisposable await act.Should().ThrowAsync(); } + [Fact] + public async Task UpdateTransfer_ChangeLinkedAccount_KeepsSharedDisplayId() + { + var source = new Account { Name = "Barkasse" }; + var initialTarget = new Account { Name = "Girokonto" }; + var newTarget = new Account { Name = "Sparkonto" }; + _db.Accounts.AddRange(source, initialTarget, newTarget); + await _db.SaveChangesAsync(); + + await _entryService.CreateTransferAsync(source.Id, initialTarget.Id, new DateTime(2026, 3, 15), "Umbuchung", 500.00m); + var sourceEntry = await _db.Entries.SingleAsync(e => e.AccountId == source.Id); + + await _entryService.UpdateTransferAsync(sourceEntry.Id, newTarget.Id, new DateTime(2026, 3, 16), "Umbuchung angepasst", 550.00m); + + _db.ChangeTracker.Clear(); + + var updatedSourceEntry = await _db.Entries.SingleAsync(e => e.AccountId == source.Id); + var updatedTargetEntry = await _db.Entries.SingleAsync(e => e.AccountId == newTarget.Id); + + updatedSourceEntry.DisplayId.Should().Be("2026-001"); + updatedTargetEntry.DisplayId.Should().Be(updatedSourceEntry.DisplayId); + } + public void Dispose() { _db.Database.CloseConnection(); diff --git a/tests/Duempelkas.Tests/YearlyStatementCalculationTests.cs b/tests/Duempelkas.Tests/YearlyStatementCalculationTests.cs index 9866e0c..b1da5b4 100644 --- a/tests/Duempelkas.Tests/YearlyStatementCalculationTests.cs +++ b/tests/Duempelkas.Tests/YearlyStatementCalculationTests.cs @@ -13,11 +13,12 @@ public class BalanceCalculationTests : IDisposable private readonly FinanceDbContext _db; private readonly BalanceQueryService _balanceQueryService; private readonly EntryService _entryService; + private readonly string _connectionString = $"Data Source=duempelkas-balance-tests-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; public BalanceCalculationTests() { var options = new DbContextOptionsBuilder() - .UseSqlite("Data Source=duempelkas-balance-tests;Mode=Memory;Cache=Shared") + .UseSqlite(_connectionString) .Options; _db = new FinanceDbContext(options);