From c3d68020d5037311c654f390d5139d177eb41db8 Mon Sep 17 00:00:00 2001 From: troogs Date: Tue, 31 Mar 2026 17:12:57 +0200 Subject: [PATCH] Add infrastructure layer with persistence and services - EF Core DbContext and entity configurations - Design-time factory for migrations - Initial and soft-delete migrations - Service implementations: Account, AccountYear, Entry, BalanceQuery, FileSave, PdfStatement - Dependency injection registration --- .../DependencyInjection.cs | 24 ++ .../Duempelkas.Infrastructure.csproj | 21 ++ .../20260329194031_InitialCreate.Designer.cs | 165 ++++++++++++ .../20260329194031_InitialCreate.cs | 141 ++++++++++ .../20260329202816_AddSoftDelete.Designer.cs | 170 ++++++++++++ .../20260329202816_AddSoftDelete.cs | 68 +++++ .../FinanceDbContextModelSnapshot.cs | 167 ++++++++++++ .../Configurations/AccountConfiguration.cs | 19 ++ .../AccountYearConfiguration.cs | 1 + .../Configurations/EntryConfiguration.cs | 25 ++ .../TransferLinkConfiguration.cs | 26 ++ .../Persistence/DesignTimeDbContextFactory.cs | 14 + .../Persistence/FinanceDbContext.cs | 18 ++ .../Services/AccountService.cs | 93 +++++++ .../Services/AccountYearService.cs | 1 + .../Services/BalanceQueryService.cs | 39 +++ .../Services/EntryService.cs | 248 ++++++++++++++++++ .../Services/FileSaveService.cs | 60 +++++ .../Services/PdfStatementService.cs | 150 +++++++++++ 19 files changed, 1450 insertions(+) create mode 100644 src/Duempelkas.Infrastructure/DependencyInjection.cs create mode 100644 src/Duempelkas.Infrastructure/Duempelkas.Infrastructure.csproj create mode 100644 src/Duempelkas.Infrastructure/Migrations/20260329194031_InitialCreate.Designer.cs create mode 100644 src/Duempelkas.Infrastructure/Migrations/20260329194031_InitialCreate.cs create mode 100644 src/Duempelkas.Infrastructure/Migrations/20260329202816_AddSoftDelete.Designer.cs create mode 100644 src/Duempelkas.Infrastructure/Migrations/20260329202816_AddSoftDelete.cs create mode 100644 src/Duempelkas.Infrastructure/Migrations/FinanceDbContextModelSnapshot.cs create mode 100644 src/Duempelkas.Infrastructure/Persistence/Configurations/AccountConfiguration.cs create mode 100644 src/Duempelkas.Infrastructure/Persistence/Configurations/AccountYearConfiguration.cs create mode 100644 src/Duempelkas.Infrastructure/Persistence/Configurations/EntryConfiguration.cs create mode 100644 src/Duempelkas.Infrastructure/Persistence/Configurations/TransferLinkConfiguration.cs create mode 100644 src/Duempelkas.Infrastructure/Persistence/DesignTimeDbContextFactory.cs create mode 100644 src/Duempelkas.Infrastructure/Persistence/FinanceDbContext.cs create mode 100644 src/Duempelkas.Infrastructure/Services/AccountService.cs create mode 100644 src/Duempelkas.Infrastructure/Services/AccountYearService.cs create mode 100644 src/Duempelkas.Infrastructure/Services/BalanceQueryService.cs create mode 100644 src/Duempelkas.Infrastructure/Services/EntryService.cs create mode 100644 src/Duempelkas.Infrastructure/Services/FileSaveService.cs create mode 100644 src/Duempelkas.Infrastructure/Services/PdfStatementService.cs diff --git a/src/Duempelkas.Infrastructure/DependencyInjection.cs b/src/Duempelkas.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..0ef170c --- /dev/null +++ b/src/Duempelkas.Infrastructure/DependencyInjection.cs @@ -0,0 +1,24 @@ +using Duempelkas.App.Services; +using Duempelkas.Infrastructure.Persistence; +using Duempelkas.Infrastructure.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Duempelkas.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString) + { + services.AddDbContext(options => + options.UseSqlite(connectionString)); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/Duempelkas.Infrastructure/Duempelkas.Infrastructure.csproj b/src/Duempelkas.Infrastructure/Duempelkas.Infrastructure.csproj new file mode 100644 index 0000000..55193c4 --- /dev/null +++ b/src/Duempelkas.Infrastructure/Duempelkas.Infrastructure.csproj @@ -0,0 +1,21 @@ + + + net10.0 + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/src/Duempelkas.Infrastructure/Migrations/20260329194031_InitialCreate.Designer.cs b/src/Duempelkas.Infrastructure/Migrations/20260329194031_InitialCreate.Designer.cs new file mode 100644 index 0000000..de8796b --- /dev/null +++ b/src/Duempelkas.Infrastructure/Migrations/20260329194031_InitialCreate.Designer.cs @@ -0,0 +1,165 @@ +// +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("20260329194031_InitialCreate")] + partial class InitialCreate + { + /// + 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("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TransferLinkId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TransferLinkId"); + + b.HasIndex("AccountId", "Date"); + + b.HasIndex("AccountId", "DisplayId") + .IsUnique(); + + 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/20260329194031_InitialCreate.cs b/src/Duempelkas.Infrastructure/Migrations/20260329194031_InitialCreate.cs new file mode 100644 index 0000000..eed1122 --- /dev/null +++ b/src/Duempelkas.Infrastructure/Migrations/20260329194031_InitialCreate.cs @@ -0,0 +1,141 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Duempelkas.Infrastructure.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Accounts", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + CarryoverBalance = table.Column(type: "decimal(18,2)", nullable: false), + CreatedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Accounts", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Entries", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + AccountId = table.Column(type: "INTEGER", nullable: false), + DisplayId = table.Column(type: "TEXT", maxLength: 20, nullable: false), + Type = table.Column(type: "INTEGER", nullable: false), + Date = table.Column(type: "TEXT", nullable: false), + Title = table.Column(type: "TEXT", maxLength: 500, nullable: false), + Amount = table.Column(type: "decimal(18,2)", nullable: false), + TransferLinkId = table.Column(type: "INTEGER", nullable: true), + CreatedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Entries", x => x.Id); + table.ForeignKey( + name: "FK_Entries_Accounts_AccountId", + column: x => x.AccountId, + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TransferLinks", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SourceEntryId = table.Column(type: "INTEGER", nullable: false), + TargetEntryId = table.Column(type: "INTEGER", nullable: false), + Note = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TransferLinks", x => x.Id); + table.ForeignKey( + name: "FK_TransferLinks_Entries_SourceEntryId", + column: x => x.SourceEntryId, + principalTable: "Entries", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_TransferLinks_Entries_TargetEntryId", + column: x => x.TargetEntryId, + principalTable: "Entries", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_Entries_AccountId_Date", + table: "Entries", + columns: new[] { "AccountId", "Date" }); + + migrationBuilder.CreateIndex( + name: "IX_Entries_AccountId_DisplayId", + table: "Entries", + columns: new[] { "AccountId", "DisplayId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Entries_TransferLinkId", + table: "Entries", + column: "TransferLinkId"); + + migrationBuilder.CreateIndex( + name: "IX_TransferLinks_SourceEntryId", + table: "TransferLinks", + column: "SourceEntryId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TransferLinks_TargetEntryId", + table: "TransferLinks", + column: "TargetEntryId", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Entries_TransferLinks_TransferLinkId", + table: "Entries", + column: "TransferLinkId", + principalTable: "TransferLinks", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Entries_Accounts_AccountId", + table: "Entries"); + + migrationBuilder.DropForeignKey( + name: "FK_Entries_TransferLinks_TransferLinkId", + table: "Entries"); + + migrationBuilder.DropTable( + name: "Accounts"); + + migrationBuilder.DropTable( + name: "TransferLinks"); + + migrationBuilder.DropTable( + name: "Entries"); + } + } +} diff --git a/src/Duempelkas.Infrastructure/Migrations/20260329202816_AddSoftDelete.Designer.cs b/src/Duempelkas.Infrastructure/Migrations/20260329202816_AddSoftDelete.Designer.cs new file mode 100644 index 0000000..471f716 --- /dev/null +++ b/src/Duempelkas.Infrastructure/Migrations/20260329202816_AddSoftDelete.Designer.cs @@ -0,0 +1,170 @@ +// +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("20260329202816_AddSoftDelete")] + partial class AddSoftDelete + { + /// + 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") + .IsUnique(); + + 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/20260329202816_AddSoftDelete.cs b/src/Duempelkas.Infrastructure/Migrations/20260329202816_AddSoftDelete.cs new file mode 100644 index 0000000..149d322 --- /dev/null +++ b/src/Duempelkas.Infrastructure/Migrations/20260329202816_AddSoftDelete.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Duempelkas.Infrastructure.Migrations +{ + /// + public partial class AddSoftDelete : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Entries_AccountId_DisplayId", + table: "Entries"); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "Entries", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.Sql(""" + WITH OrderedEntries AS ( + SELECT + Id, + CAST(strftime('%Y', Date) AS INTEGER) AS EntryYear, + ROW_NUMBER() OVER ( + PARTITION BY strftime('%Y', Date) + ORDER BY Date, CreatedUtc, Id + ) AS SequenceNumber + FROM Entries + ) + UPDATE Entries + SET DisplayId = ( + SELECT printf('%04d-%03d', OrderedEntries.EntryYear, OrderedEntries.SequenceNumber) + FROM OrderedEntries + WHERE OrderedEntries.Id = Entries.Id + ); + """); + + migrationBuilder.CreateIndex( + name: "IX_Entries_DisplayId", + table: "Entries", + column: "DisplayId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Entries_DisplayId", + table: "Entries"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "Entries"); + + migrationBuilder.CreateIndex( + name: "IX_Entries_AccountId_DisplayId", + table: "Entries", + columns: new[] { "AccountId", "DisplayId" }, + unique: true); + } + } +} diff --git a/src/Duempelkas.Infrastructure/Migrations/FinanceDbContextModelSnapshot.cs b/src/Duempelkas.Infrastructure/Migrations/FinanceDbContextModelSnapshot.cs new file mode 100644 index 0000000..a6f67c5 --- /dev/null +++ b/src/Duempelkas.Infrastructure/Migrations/FinanceDbContextModelSnapshot.cs @@ -0,0 +1,167 @@ +// +using System; +using Duempelkas.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Duempelkas.Infrastructure.Migrations +{ + [DbContext(typeof(FinanceDbContext))] + partial class FinanceDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(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") + .IsUnique(); + + 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/Persistence/Configurations/AccountConfiguration.cs b/src/Duempelkas.Infrastructure/Persistence/Configurations/AccountConfiguration.cs new file mode 100644 index 0000000..7f9232e --- /dev/null +++ b/src/Duempelkas.Infrastructure/Persistence/Configurations/AccountConfiguration.cs @@ -0,0 +1,19 @@ +using Duempelkas.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Duempelkas.Infrastructure.Persistence.Configurations; + +public class AccountConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(a => a.Id); + builder.Property(a => a.Name).IsRequired().HasMaxLength(200); + builder.Property(a => a.CarryoverBalance).HasColumnType("decimal(18,2)"); + builder.HasMany(a => a.Entries) + .WithOne(e => e.Account) + .HasForeignKey(e => e.AccountId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/Duempelkas.Infrastructure/Persistence/Configurations/AccountYearConfiguration.cs b/src/Duempelkas.Infrastructure/Persistence/Configurations/AccountYearConfiguration.cs new file mode 100644 index 0000000..4495d0b --- /dev/null +++ b/src/Duempelkas.Infrastructure/Persistence/Configurations/AccountYearConfiguration.cs @@ -0,0 +1 @@ +// This file is intentionally left empty. AccountYear has been removed from the data model. diff --git a/src/Duempelkas.Infrastructure/Persistence/Configurations/EntryConfiguration.cs b/src/Duempelkas.Infrastructure/Persistence/Configurations/EntryConfiguration.cs new file mode 100644 index 0000000..c4a9675 --- /dev/null +++ b/src/Duempelkas.Infrastructure/Persistence/Configurations/EntryConfiguration.cs @@ -0,0 +1,25 @@ +using Duempelkas.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Duempelkas.Infrastructure.Persistence.Configurations; + +public class EntryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + builder.Property(e => e.DisplayId).IsRequired().HasMaxLength(20); + builder.HasIndex(e => e.DisplayId).IsUnique(); + builder.Property(e => e.Title).IsRequired().HasMaxLength(500); + builder.Property(e => e.Amount).HasColumnType("decimal(18,2)"); + builder.Property(e => e.Type).HasConversion(); + builder.Property(e => e.IsDeleted).HasDefaultValue(false); + builder.HasIndex(e => new { e.AccountId, e.Date }); + builder.HasOne(e => e.TransferLink) + .WithMany() + .HasForeignKey(e => e.TransferLinkId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + } +} diff --git a/src/Duempelkas.Infrastructure/Persistence/Configurations/TransferLinkConfiguration.cs b/src/Duempelkas.Infrastructure/Persistence/Configurations/TransferLinkConfiguration.cs new file mode 100644 index 0000000..30dd7ea --- /dev/null +++ b/src/Duempelkas.Infrastructure/Persistence/Configurations/TransferLinkConfiguration.cs @@ -0,0 +1,26 @@ +using Duempelkas.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Duempelkas.Infrastructure.Persistence.Configurations; + +public class TransferLinkConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(t => t.Id); + builder.Property(t => t.Note).HasMaxLength(500); + builder.HasIndex(t => t.SourceEntryId).IsUnique(); + builder.HasIndex(t => t.TargetEntryId).IsUnique(); + + builder.HasOne(t => t.SourceEntry) + .WithMany() + .HasForeignKey(t => t.SourceEntryId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(t => t.TargetEntry) + .WithMany() + .HasForeignKey(t => t.TargetEntryId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/Duempelkas.Infrastructure/Persistence/DesignTimeDbContextFactory.cs b/src/Duempelkas.Infrastructure/Persistence/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..c30d2ff --- /dev/null +++ b/src/Duempelkas.Infrastructure/Persistence/DesignTimeDbContextFactory.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Duempelkas.Infrastructure.Persistence; + +public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public FinanceDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlite("Data Source=duempelkas_design.db"); + return new FinanceDbContext(optionsBuilder.Options); + } +} diff --git a/src/Duempelkas.Infrastructure/Persistence/FinanceDbContext.cs b/src/Duempelkas.Infrastructure/Persistence/FinanceDbContext.cs new file mode 100644 index 0000000..1b914ad --- /dev/null +++ b/src/Duempelkas.Infrastructure/Persistence/FinanceDbContext.cs @@ -0,0 +1,18 @@ +using Duempelkas.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Duempelkas.Infrastructure.Persistence; + +public class FinanceDbContext : DbContext +{ + public FinanceDbContext(DbContextOptions options) : base(options) { } + + public DbSet Accounts => Set(); + public DbSet Entries => Set(); + public DbSet TransferLinks => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(FinanceDbContext).Assembly); + } +} diff --git a/src/Duempelkas.Infrastructure/Services/AccountService.cs b/src/Duempelkas.Infrastructure/Services/AccountService.cs new file mode 100644 index 0000000..536cd36 --- /dev/null +++ b/src/Duempelkas.Infrastructure/Services/AccountService.cs @@ -0,0 +1,93 @@ +using Duempelkas.App.Services; +using Duempelkas.App.Services.Models; +using Duempelkas.Domain.Entities; +using Duempelkas.Domain.Enums; +using Duempelkas.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Duempelkas.Infrastructure.Services; + +public class AccountService : IAccountService +{ + private readonly FinanceDbContext _db; + + public AccountService(FinanceDbContext db) => _db = db; + + public async Task> GetAllAccountsAsync() + { + var accounts = await _db.Accounts + .Include(a => a.Entries) + .OrderBy(a => a.Name) + .ToListAsync(); + + return accounts.Select(MapToSummary).ToList(); + } + + public async Task GetAccountAsync(int accountId) + { + var account = await _db.Accounts + .Include(a => a.Entries) + .FirstOrDefaultAsync(a => a.Id == accountId) + ?? throw new InvalidOperationException($"Account {accountId} not found."); + + return MapToSummary(account); + } + + public async Task CreateAccountAsync(string name) + { + var account = new Account { Name = name }; + _db.Accounts.Add(account); + await _db.SaveChangesAsync(); + return new AccountSummaryDto(account.Id, account.Name, 0m, 0m, account.CreatedUtc); + } + + public async Task RenameAccountAsync(int accountId, string newName) + { + var account = await _db.Accounts.FindAsync(accountId) + ?? throw new InvalidOperationException($"Account {accountId} not found."); + account.Name = newName; + await _db.SaveChangesAsync(); + } + + public async Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance) + { + var account = await _db.Accounts.FindAsync(accountId) + ?? throw new InvalidOperationException($"Account {accountId} not found."); + account.CarryoverBalance = carryoverBalance; + await _db.SaveChangesAsync(); + } + + public async Task DeleteAccountAsync(int accountId) + { + var account = await _db.Accounts + .Include(a => a.Entries) + .FirstOrDefaultAsync(a => a.Id == accountId) + ?? throw new InvalidOperationException($"Account {accountId} not found."); + + var entryIds = account.Entries.Select(e => e.Id).ToList(); + var transferLinks = await _db.TransferLinks + .Where(tl => entryIds.Contains(tl.SourceEntryId) || entryIds.Contains(tl.TargetEntryId)) + .ToListAsync(); + + foreach (var link in transferLinks) + { + var otherEntryId = entryIds.Contains(link.SourceEntryId) ? link.TargetEntryId : link.SourceEntryId; + var otherEntry = await _db.Entries.FindAsync(otherEntryId); + if (otherEntry != null) + _db.Entries.Remove(otherEntry); + } + _db.TransferLinks.RemoveRange(transferLinks); + + _db.Accounts.Remove(account); + await _db.SaveChangesAsync(); + } + + private static AccountSummaryDto MapToSummary(Account account) + { + var totalIncome = account.Entries.Where(e => e.Type == EntryType.Income).Sum(e => e.Amount); + var totalExpense = account.Entries.Where(e => e.Type == EntryType.Expense).Sum(e => e.Amount); + var totalBalance = account.CarryoverBalance + totalIncome - totalExpense; + + return new AccountSummaryDto(account.Id, account.Name, account.CarryoverBalance, totalBalance, account.CreatedUtc); + } +} diff --git a/src/Duempelkas.Infrastructure/Services/AccountYearService.cs b/src/Duempelkas.Infrastructure/Services/AccountYearService.cs new file mode 100644 index 0000000..4495d0b --- /dev/null +++ b/src/Duempelkas.Infrastructure/Services/AccountYearService.cs @@ -0,0 +1 @@ +// This file is intentionally left empty. AccountYear has been removed from the data model. diff --git a/src/Duempelkas.Infrastructure/Services/BalanceQueryService.cs b/src/Duempelkas.Infrastructure/Services/BalanceQueryService.cs new file mode 100644 index 0000000..4368a17 --- /dev/null +++ b/src/Duempelkas.Infrastructure/Services/BalanceQueryService.cs @@ -0,0 +1,39 @@ +using Duempelkas.App.Services; +using Duempelkas.App.Services.Models; +using Duempelkas.Domain.Enums; +using Duempelkas.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Duempelkas.Infrastructure.Services; + +public class BalanceQueryService : IBalanceQueryService +{ + private readonly FinanceDbContext _db; + + public BalanceQueryService(FinanceDbContext db) => _db = db; + + public async Task GetAccountBalanceAsync(int accountId) + { + var account = await _db.Accounts + .Include(a => a.Entries) + .FirstOrDefaultAsync(a => a.Id == accountId) + ?? throw new InvalidOperationException($"Konto {accountId} nicht gefunden."); + + var currentYear = DateTime.Now.Year; + + var activeEntries = account.Entries.Where(e => !e.IsDeleted).ToList(); + var totalIncome = activeEntries.Where(e => e.Type == EntryType.Income).Sum(e => e.Amount); + var totalExpense = activeEntries.Where(e => e.Type == EntryType.Expense).Sum(e => e.Amount); + var currentYearIncome = activeEntries.Where(e => e.Type == EntryType.Income && e.Date.Year == currentYear).Sum(e => e.Amount); + var currentYearExpense = activeEntries.Where(e => e.Type == EntryType.Expense && e.Date.Year == currentYear).Sum(e => e.Amount); + var totalBalance = account.CarryoverBalance + totalIncome - totalExpense; + + return new AccountBalanceDto( + account.CarryoverBalance, + totalIncome, + totalExpense, + currentYearIncome, + currentYearExpense, + totalBalance); + } +} diff --git a/src/Duempelkas.Infrastructure/Services/EntryService.cs b/src/Duempelkas.Infrastructure/Services/EntryService.cs new file mode 100644 index 0000000..1f8df2a --- /dev/null +++ b/src/Duempelkas.Infrastructure/Services/EntryService.cs @@ -0,0 +1,248 @@ +using Duempelkas.App.Services; +using Duempelkas.App.Services.Models; +using Duempelkas.Domain.Entities; +using Duempelkas.Domain.Enums; +using Duempelkas.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Duempelkas.Infrastructure.Services; + +public class EntryService : IEntryService +{ + private readonly FinanceDbContext _db; + + public EntryService(FinanceDbContext db) => _db = db; + + public async Task> GetEntriesAsync(int accountId, bool currentYearOnly) + { + var query = _db.Entries.Where(e => e.AccountId == accountId); + + if (currentYearOnly) + query = query.Where(e => e.Date.Year == DateTime.Now.Year); + + var entries = await query + .OrderBy(e => e.Date) + .ThenBy(e => e.CreatedUtc) + .ToListAsync(); + + var entryIds = entries.Select(e => e.Id).ToList(); + + var transferLinks = await _db.TransferLinks + .Include(tl => tl.SourceEntry).ThenInclude(e => e.Account) + .Include(tl => tl.TargetEntry).ThenInclude(e => e.Account) + .Where(tl => entryIds.Contains(tl.SourceEntryId) || entryIds.Contains(tl.TargetEntryId)) + .ToListAsync(); + + return entries.Select(e => + { + var link = transferLinks.FirstOrDefault(tl => tl.SourceEntryId == e.Id || tl.TargetEntryId == e.Id); + string? linkedAccountName = null; + int? linkedAccountId = null; + if (link != null) + { + if (link.SourceEntryId == e.Id) + { + linkedAccountName = link.TargetEntry.Account.Name; + linkedAccountId = link.TargetEntry.AccountId; + } + else + { + linkedAccountName = link.SourceEntry.Account.Name; + linkedAccountId = link.SourceEntry.AccountId; + } + } + return new EntryDto( + e.Id, e.AccountId, e.DisplayId, e.Type, e.Date, e.Title, e.Amount, + e.IsDeleted, link != null, link?.Id, linkedAccountName, linkedAccountId); + }).ToList(); + } + + public async Task CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount) + { + var displayId = await GenerateDisplayIdAsync(accountId, date.Year); + + var entry = new Entry + { + AccountId = accountId, + DisplayId = displayId, + Type = type, + Date = date, + Title = title, + Amount = amount + }; + _db.Entries.Add(entry); + await _db.SaveChangesAsync(); + + return new EntryDto(entry.Id, entry.AccountId, entry.DisplayId, entry.Type, entry.Date, entry.Title, entry.Amount, false, false, null, null, null); + } + + public async Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount) + { + if (sourceAccountId == targetAccountId) + throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich."); + + await using var transaction = await _db.Database.BeginTransactionAsync(); + + var sourceDisplayId = await GenerateDisplayIdAsync(sourceAccountId, date.Year); + + var sourceEntry = new Entry + { + AccountId = sourceAccountId, + DisplayId = sourceDisplayId, + Type = EntryType.Expense, + Date = date, + Title = title, + Amount = amount + }; + + _db.Entries.Add(sourceEntry); + await _db.SaveChangesAsync(); + + var targetDisplayId = await GenerateDisplayIdAsync(targetAccountId, date.Year); + + var targetEntry = new Entry + { + AccountId = targetAccountId, + DisplayId = targetDisplayId, + Type = EntryType.Income, + Date = date, + Title = title, + Amount = amount + }; + + _db.Entries.Add(targetEntry); + await _db.SaveChangesAsync(); + + var link = new TransferLink + { + SourceEntryId = sourceEntry.Id, + TargetEntryId = targetEntry.Id, + Note = $"Umbuchung: {title}" + }; + _db.TransferLinks.Add(link); + await _db.SaveChangesAsync(); + + sourceEntry.TransferLinkId = link.Id; + targetEntry.TransferLinkId = link.Id; + await _db.SaveChangesAsync(); + + await transaction.CommitAsync(); + } + + public async Task DeleteEntryAsync(int entryId) + { + var entry = await _db.Entries.FindAsync(entryId) + ?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden."); + + entry.IsDeleted = true; + + var link = await _db.TransferLinks + .FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId); + + if (link != null) + { + var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId; + var otherEntry = await _db.Entries.FindAsync(otherEntryId); + if (otherEntry != null) otherEntry.IsDeleted = true; + } + + await _db.SaveChangesAsync(); + } + + public async Task RestoreEntryAsync(int entryId) + { + var entry = await _db.Entries.FindAsync(entryId) + ?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden."); + + entry.IsDeleted = false; + + var link = await _db.TransferLinks + .FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId); + + if (link != null) + { + var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId; + var otherEntry = await _db.Entries.FindAsync(otherEntryId); + if (otherEntry != null) otherEntry.IsDeleted = false; + } + + await _db.SaveChangesAsync(); + } + + public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount) + { + var entry = await _db.Entries.FindAsync(entryId) + ?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden."); + + var link = await _db.TransferLinks + .FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId); + + entry.Date = date; + entry.Title = title; + entry.Amount = amount; + + if (link != null) + { + var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId; + var otherEntry = await _db.Entries.FindAsync(otherEntryId); + if (otherEntry != null) + { + otherEntry.Date = date; + otherEntry.Title = title; + otherEntry.Amount = amount; + } + } + + await _db.SaveChangesAsync(); + } + + public async Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount) + { + var entry = await _db.Entries.FindAsync(entryId) + ?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden."); + + var link = await _db.TransferLinks + .FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId) + ?? throw new InvalidOperationException($"Kein Transfer-Link für Eintrag {entryId} gefunden."); + + var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId; + var otherEntry = await _db.Entries.FindAsync(otherEntryId) + ?? throw new InvalidOperationException($"Gegeneintrag {otherEntryId} nicht gefunden."); + + // Update date, title, amount on both sides + entry.Date = date; + entry.Title = title; + entry.Amount = amount; + otherEntry.Date = date; + otherEntry.Title = title; + otherEntry.Amount = amount; + + // If the linked account has changed, move the other entry to the new account + if (otherEntry.AccountId != newLinkedAccountId) + { + otherEntry.AccountId = newLinkedAccountId; + otherEntry.DisplayId = await GenerateDisplayIdAsync(newLinkedAccountId, date.Year); + } + + await _db.SaveChangesAsync(); + } + + private async Task GenerateDisplayIdAsync(int accountId, int year) + { + var prefix = $"{year}-"; + var maxDisplayId = await _db.Entries + .Where(e => e.DisplayId.StartsWith(prefix)) + .Select(e => e.DisplayId) + .MaxAsync(id => (string?)id); + + int nextNumber = 1; + if (maxDisplayId != null) + { + var parts = maxDisplayId.Split('-'); + if (parts.Length == 2 && int.TryParse(parts[1], out var current)) + nextNumber = current + 1; + } + + return $"{year}-{nextNumber:D3}"; + } +} diff --git a/src/Duempelkas.Infrastructure/Services/FileSaveService.cs b/src/Duempelkas.Infrastructure/Services/FileSaveService.cs new file mode 100644 index 0000000..b07fa89 --- /dev/null +++ b/src/Duempelkas.Infrastructure/Services/FileSaveService.cs @@ -0,0 +1,60 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using Duempelkas.App.Services; + +namespace Duempelkas.Infrastructure.Services; + +public class FileSaveService : IFileSaveService +{ + public async Task SaveFileAsync(byte[] content, string suggestedFileName) + { + var filePath = await ShowSaveDialogAsync(suggestedFileName); + if (filePath == null) return null; + + await File.WriteAllBytesAsync(filePath, content); + return filePath; + } + + private static Task ShowSaveDialogAsync(string suggestedFileName) + { + return Task.Run(() => + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Fallback: save to Documents folder + var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + return Path.Combine(documentsPath, suggestedFileName); + } + + var extension = Path.GetExtension(suggestedFileName); + var filterName = extension == ".pdf" ? "PDF-Dateien" : "Dateien"; + var filter = $"{filterName} (*{extension})|*{extension}|Alle Dateien (*.*)|*.*"; + + var script = $@" +Add-Type -AssemblyName System.Windows.Forms +$dialog = New-Object System.Windows.Forms.SaveFileDialog +$dialog.Filter = '{filter}' +$dialog.FileName = '{suggestedFileName.Replace("'", "''")}' +$dialog.Title = 'Datei speichern' +if ($dialog.ShowDialog() -eq 'OK') {{ $dialog.FileName }} else {{ '' }} +"; + + var psi = new ProcessStartInfo + { + FileName = "powershell", + Arguments = $"-NoProfile -Command \"{script.Replace("\"", "\\\"\"")}\"", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process == null) return null; + + var result = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(); + + return string.IsNullOrWhiteSpace(result) ? null : result; + }); + } +} diff --git a/src/Duempelkas.Infrastructure/Services/PdfStatementService.cs b/src/Duempelkas.Infrastructure/Services/PdfStatementService.cs new file mode 100644 index 0000000..b611982 --- /dev/null +++ b/src/Duempelkas.Infrastructure/Services/PdfStatementService.cs @@ -0,0 +1,150 @@ +using System.Globalization; +using Duempelkas.App.Services; +using Duempelkas.App.Services.Models; +using Duempelkas.Domain.Enums; +using Duempelkas.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; + +namespace Duempelkas.Infrastructure.Services; + +public class PdfStatementService : IPdfStatementService +{ + private readonly FinanceDbContext _db; + private readonly IEntryService _entryService; + private readonly IBalanceQueryService _balanceQueryService; + private static readonly CultureInfo DeLocale = new("de-DE"); + + public PdfStatementService(FinanceDbContext db, IEntryService entryService, IBalanceQueryService balanceQueryService) + { + _db = db; + _entryService = entryService; + _balanceQueryService = balanceQueryService; + } + + public async Task GenerateStatementAsync(int accountId, bool currentYearOnly) + { + var account = await _db.Accounts.FindAsync(accountId) + ?? throw new InvalidOperationException($"Konto {accountId} nicht gefunden."); + + var entries = await _entryService.GetEntriesAsync(accountId, currentYearOnly); + var balance = await _balanceQueryService.GetAccountBalanceAsync(accountId); + + var title = currentYearOnly + ? $"{account.Name} – Auszug {DateTime.Now.Year}" + : $"{account.Name} – Gesamtauszug"; + + var document = Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4); + page.MarginHorizontal(40); + page.MarginVertical(30); + page.DefaultTextStyle(x => x.FontSize(10)); + + page.Header().Column(col => + { + col.Item().Text(account.Name).Bold().FontSize(20); + col.Item().Text(title).FontSize(14).FontColor(Colors.Grey.Darken1); + col.Item().PaddingTop(5).Text($"Erstellt am: {DateTime.Now:dd.MM.yyyy}").FontSize(8).FontColor(Colors.Grey.Medium); + col.Item().PaddingTop(10).LineHorizontal(1).LineColor(Colors.Grey.Lighten2); + }); + + page.Content().PaddingTop(15).Column(col => + { + col.Item().PaddingBottom(10).Row(row => + { + row.RelativeItem().Text("Übertrag:").SemiBold(); + row.AutoItem().Text(FormatCurrency(balance.CarryoverBalance)).SemiBold(); + }); + + col.Item().Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(80); + columns.ConstantColumn(80); + columns.RelativeColumn(); + columns.ConstantColumn(100); + }); + + table.Header(header => + { + header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1) + .PaddingBottom(5).Text("Datum").SemiBold(); + header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1) + .PaddingBottom(5).Text("Nr.").SemiBold(); + header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1) + .PaddingBottom(5).Text("Bezeichnung").SemiBold(); + header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1) + .PaddingBottom(5).AlignRight().Text("Betrag").SemiBold(); + }); + + foreach (var entry in entries) + { + var titleText = entry.Title; + if (entry.IsTransfer && !string.IsNullOrEmpty(entry.LinkedAccountName)) + { + var prefix = entry.Type == EntryType.Expense ? "an" : "von"; + titleText += $" ({prefix} {entry.LinkedAccountName})"; + } + + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2) + .PaddingVertical(3).Text(entry.Date.ToString("dd.MM.yyyy")); + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2) + .PaddingVertical(3).Text(entry.DisplayId); + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2) + .PaddingVertical(3).Text(titleText); + table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2) + .PaddingVertical(3).AlignRight() + .Text($"{(entry.Type == EntryType.Income ? "+" : "−")}{FormatCurrency(entry.Amount)}") + .FontColor(entry.Type == EntryType.Income ? Colors.Green.Darken1 : Colors.Red.Darken1); + } + }); + + col.Item().PaddingTop(20).LineHorizontal(1).LineColor(Colors.Grey.Lighten2); + col.Item().PaddingTop(10).Column(summaryCol => + { + SummaryRow(summaryCol, "Übertrag:", balance.CarryoverBalance); + SummaryRow(summaryCol, "Einnahmen gesamt:", balance.TotalIncome); + SummaryRow(summaryCol, "Ausgaben gesamt:", -balance.TotalExpense); + summaryCol.Item().PaddingTop(5).Row(row => + { + row.RelativeItem().Text("Saldo:").Bold().FontSize(12); + row.AutoItem().Text(FormatCurrency(balance.TotalBalance)).Bold().FontSize(12) + .FontColor(balance.TotalBalance >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1); + }); + }); + }); + + page.Footer().AlignCenter().Text(text => + { + text.Span("Seite "); + text.CurrentPageNumber(); + text.Span(" von "); + text.TotalPages(); + }); + }); + }); + + return document.GeneratePdf(); + } + + private static void SummaryRow(ColumnDescriptor col, string label, decimal value) + { + col.Item().PaddingVertical(2).Row(row => + { + row.RelativeItem().Text(label); + row.AutoItem().Text(FormatCurrency(value)) + .FontColor(value >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1); + }); + } + + private static string FormatCurrency(decimal amount) + { + return amount.ToString("N2", DeLocale) + " €"; + } +}