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
This commit is contained in:
24
src/Duempelkas.Infrastructure/DependencyInjection.cs
Normal file
24
src/Duempelkas.Infrastructure/DependencyInjection.cs
Normal file
@@ -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<FinanceDbContext>(options =>
|
||||
options.UseSqlite(connectionString));
|
||||
|
||||
services.AddScoped<IAccountService, AccountService>();
|
||||
services.AddScoped<IEntryService, EntryService>();
|
||||
services.AddScoped<IBalanceQueryService, BalanceQueryService>();
|
||||
services.AddScoped<IPdfStatementService, PdfStatementService>();
|
||||
services.AddScoped<IFileSaveService, FileSaveService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.*" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.*">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="QuestPDF" Version="*" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Duempelkas.Domain\Duempelkas.Domain.csproj" />
|
||||
<ProjectReference Include="..\Duempelkas.App\Duempelkas.App.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
165
src/Duempelkas.Infrastructure/Migrations/20260329194031_InitialCreate.Designer.cs
generated
Normal file
165
src/Duempelkas.Infrastructure/Migrations/20260329194031_InitialCreate.Designer.cs
generated
Normal file
@@ -0,0 +1,165 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("CarryoverBalance")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Accounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Duempelkas.Domain.Entities.Entry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AccountId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TransferLinkId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SourceEntryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Duempelkas.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Accounts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
CarryoverBalance = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Accounts", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Entries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
AccountId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
DisplayId = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||
Type = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Date = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
TransferLinkId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
CreatedUtc = table.Column<DateTime>(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<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
SourceEntryId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
TargetEntryId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Note = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
|
||||
CreatedUtc = table.Column<DateTime>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
170
src/Duempelkas.Infrastructure/Migrations/20260329202816_AddSoftDelete.Designer.cs
generated
Normal file
170
src/Duempelkas.Infrastructure/Migrations/20260329202816_AddSoftDelete.Designer.cs
generated
Normal file
@@ -0,0 +1,170 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("CarryoverBalance")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Accounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Duempelkas.Domain.Entities.Entry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AccountId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TransferLinkId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SourceEntryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Duempelkas.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSoftDelete : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Entries_AccountId_DisplayId",
|
||||
table: "Entries");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// <auto-generated />
|
||||
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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("CarryoverBalance")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Accounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Duempelkas.Domain.Entities.Entry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AccountId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TransferLinkId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SourceEntryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Account>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Account> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// This file is intentionally left empty. AccountYear has been removed from the data model.
|
||||
@@ -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<Entry>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Entry> 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<int>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<TransferLink>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TransferLink> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace Duempelkas.Infrastructure.Persistence;
|
||||
|
||||
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<FinanceDbContext>
|
||||
{
|
||||
public FinanceDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<FinanceDbContext>();
|
||||
optionsBuilder.UseSqlite("Data Source=duempelkas_design.db");
|
||||
return new FinanceDbContext(optionsBuilder.Options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Duempelkas.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Duempelkas.Infrastructure.Persistence;
|
||||
|
||||
public class FinanceDbContext : DbContext
|
||||
{
|
||||
public FinanceDbContext(DbContextOptions<FinanceDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<Account> Accounts => Set<Account>();
|
||||
public DbSet<Entry> Entries => Set<Entry>();
|
||||
public DbSet<TransferLink> TransferLinks => Set<TransferLink>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(FinanceDbContext).Assembly);
|
||||
}
|
||||
}
|
||||
93
src/Duempelkas.Infrastructure/Services/AccountService.cs
Normal file
93
src/Duempelkas.Infrastructure/Services/AccountService.cs
Normal file
@@ -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<List<AccountSummaryDto>> GetAllAccountsAsync()
|
||||
{
|
||||
var accounts = await _db.Accounts
|
||||
.Include(a => a.Entries)
|
||||
.OrderBy(a => a.Name)
|
||||
.ToListAsync();
|
||||
|
||||
return accounts.Select(MapToSummary).ToList();
|
||||
}
|
||||
|
||||
public async Task<AccountSummaryDto> 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<AccountSummaryDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// This file is intentionally left empty. AccountYear has been removed from the data model.
|
||||
@@ -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<AccountBalanceDto> 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);
|
||||
}
|
||||
}
|
||||
248
src/Duempelkas.Infrastructure/Services/EntryService.cs
Normal file
248
src/Duempelkas.Infrastructure/Services/EntryService.cs
Normal file
@@ -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<List<EntryDto>> 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<EntryDto> 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<string> 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}";
|
||||
}
|
||||
}
|
||||
60
src/Duempelkas.Infrastructure/Services/FileSaveService.cs
Normal file
60
src/Duempelkas.Infrastructure/Services/FileSaveService.cs
Normal file
@@ -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<string?> 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<string?> 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
150
src/Duempelkas.Infrastructure/Services/PdfStatementService.cs
Normal file
150
src/Duempelkas.Infrastructure/Services/PdfStatementService.cs
Normal file
@@ -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<byte[]> 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) + " €";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user