Compare commits
2 Commits
387c18e834
...
9aa1fee49e
| Author | SHA1 | Date | |
|---|---|---|---|
| 9aa1fee49e | |||
| 69181e66b0 |
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -6,7 +6,7 @@
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
"program": "${workspaceFolder}/src/Duempelkas.Desktop/bin/Debug/net10.0/Duempelkas.Desktop.dll",
|
||||
"program": "${workspaceFolder}/src/Duempelkas.Desktop/bin/Debug/net10.0/Duempelkas.Desktop.exe",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/src/Duempelkas.Desktop",
|
||||
"stopAtEntry": false,
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
@inject IAccountService AccountService
|
||||
@inject IBackupService BackupService
|
||||
@inject ISettingsService SettingsService
|
||||
@inject IPdfStatementService PdfStatementService
|
||||
@inject IFileSaveService FileSaveService
|
||||
@inject IJSRuntime JsRuntime
|
||||
@inject NavigationManager NavigationManager
|
||||
@using System.Globalization
|
||||
|
||||
<div class="container-fluid">
|
||||
|
||||
@@ -29,8 +32,22 @@
|
||||
<button class="btn-nav btn-warning" @onclick="HandleRestoreAsync">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> Restore
|
||||
</button>
|
||||
|
|
||||
<button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync">
|
||||
<i class="bi bi-file-earmark-pdf"></i> PDF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (accounts != null && accounts.Any())
|
||||
{
|
||||
<div class="alert alert-secondary py-2 px-3 mb-3" role="status">
|
||||
<strong>Summe aller Konten:</strong>
|
||||
<span class="ms-2 @(TotalClubBalance >= 0 ? "text-success" : "text-danger")">
|
||||
@FormatCurrency(TotalClubBalance)
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(operationMessage))
|
||||
{
|
||||
<div class="alert @operationMessageClass mb-3" role="alert">
|
||||
@@ -68,6 +85,18 @@
|
||||
OnSave="HandleSaveClubName" OnCancel="() => showEditClubName = false" />
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||
{
|
||||
<ConfirmDialog Title="PDF öffnen"
|
||||
Message="Die PDF wurde gespeichert. Möchten Sie sie jetzt öffnen?"
|
||||
ConfirmText="Ja, öffnen"
|
||||
CancelText="Nein"
|
||||
ConfirmButtonClass="btn btn-primary"
|
||||
ConfirmIconClass="bi bi-box-arrow-up-right"
|
||||
OnConfirm="HandleOpenSavedPdf"
|
||||
OnCancel="CancelOpenSavedPdf" />
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<AccountSummaryDto>? accounts;
|
||||
private bool showAddAccount;
|
||||
@@ -75,7 +104,9 @@
|
||||
private string clubName = string.Empty;
|
||||
private string? operationMessage;
|
||||
private string operationMessageClass = "alert-info";
|
||||
private string? savedPdfPath;
|
||||
private string DisplayClubName => string.IsNullOrWhiteSpace(clubName) ? "Mein Verein" : clubName;
|
||||
private decimal TotalClubBalance => accounts?.Sum(a => a.TotalBalance) ?? 0m;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -136,9 +167,35 @@
|
||||
|
||||
}
|
||||
|
||||
private async Task HandleDashboardExportAsync()
|
||||
{
|
||||
var pdf = await PdfStatementService.GenerateDashboardStatementAsync();
|
||||
savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{DisplayClubName}_Übersicht.pdf");
|
||||
}
|
||||
|
||||
private async Task HandleOpenSavedPdf()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||
{
|
||||
await FileSaveService.OpenFileAsync(savedPdfPath);
|
||||
}
|
||||
|
||||
savedPdfPath = null;
|
||||
}
|
||||
|
||||
private void CancelOpenSavedPdf()
|
||||
{
|
||||
savedPdfPath = null;
|
||||
}
|
||||
|
||||
private void SetOperationMessage(string message, bool success)
|
||||
{
|
||||
operationMessage = message;
|
||||
operationMessageClass = success ? "alert-success" : "alert-danger";
|
||||
}
|
||||
|
||||
private static string FormatCurrency(decimal amount)
|
||||
{
|
||||
return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,5 @@ namespace Duempelkas.App.Services;
|
||||
public interface IPdfStatementService
|
||||
{
|
||||
Task<byte[]> GenerateStatementAsync(int accountId, bool currentYearOnly);
|
||||
Task<byte[]> GenerateDashboardStatementAsync();
|
||||
}
|
||||
|
||||
BIN
src/Duempelkas.Desktop/Assets/app-icon.ico
Normal file
BIN
src/Duempelkas.Desktop/Assets/app-icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
BIN
src/Duempelkas.Desktop/Assets/app-icon.png
Normal file
BIN
src/Duempelkas.Desktop/Assets/app-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
@@ -3,6 +3,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>Exe</OutputType>
|
||||
<OutputType Condition="$([MSBuild]::IsOSPlatform('Windows'))">WinExe</OutputType>
|
||||
<ApplicationIcon>Assets\app-icon.ico</ApplicationIcon>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>$([System.DateTime]::Now.ToString('yy')).$([System.DateTime]::Now.DayOfYear)</Version>
|
||||
@@ -23,6 +24,9 @@
|
||||
<Content Include="wwwroot\**">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Assets\app-icon.ico">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -3,18 +3,49 @@ using Duempelkas.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Photino.Blazor;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Duempelkas.Desktop;
|
||||
|
||||
class Program
|
||||
{
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int SetCurrentProcessExplicitAppUserModelID(string appID);
|
||||
|
||||
[STAThread]
|
||||
static void Main(string[] args)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// Ensure Windows taskbar does not group this process under generic dotnet host identity.
|
||||
SetCurrentProcessExplicitAppUserModelID("Duempelkas.Desktop");
|
||||
}
|
||||
|
||||
var appBuilder = PhotinoBlazorAppBuilder.CreateDefault(args);
|
||||
|
||||
var exeDirectory = Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory;
|
||||
var dbPath = Path.Combine(exeDirectory, "duempelkas.db");
|
||||
// Use a per-user writable DB location so debugging via dotnet host works reliably.
|
||||
var appDataDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"Duempelkas");
|
||||
Directory.CreateDirectory(appDataDir);
|
||||
|
||||
var dbPath = Path.Combine(appDataDir, "duempelkas.db");
|
||||
|
||||
// One-time compatibility: keep existing data if older versions stored DB next to the app executable.
|
||||
var legacyExeDirectory = Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory;
|
||||
var legacyDbPath = Path.Combine(legacyExeDirectory, "duempelkas.db");
|
||||
if (!File.Exists(dbPath) && File.Exists(legacyDbPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Copy(legacyDbPath, dbPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore copy errors and continue with a fresh DB.
|
||||
}
|
||||
}
|
||||
|
||||
appBuilder.Services.AddInfrastructure($"Data Source={dbPath}");
|
||||
|
||||
appBuilder.RootComponents.Add<Duempelkas.App.Components.App>("app");
|
||||
@@ -23,6 +54,13 @@ class Program
|
||||
|
||||
app.MainWindow.StartUrl = PhotinoWebViewManager.AppBaseUri;
|
||||
|
||||
// Force the native window icon for title bar + taskbar representation.
|
||||
var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "app-icon.ico");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
app.MainWindow.SetIconFile(iconPath);
|
||||
}
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<FinanceDbContext>();
|
||||
|
||||
169
src/Duempelkas.Infrastructure/Migrations/20260403093901_AllowSharedDisplayIdForTransfers.Designer.cs
generated
Normal file
169
src/Duempelkas.Infrastructure/Migrations/20260403093901_AllowSharedDisplayIdForTransfers.Designer.cs
generated
Normal file
@@ -0,0 +1,169 @@
|
||||
// <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("20260403093901_AllowSharedDisplayIdForTransfers")]
|
||||
partial class AllowSharedDisplayIdForTransfers
|
||||
{
|
||||
/// <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");
|
||||
|
||||
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,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Duempelkas.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AllowSharedDisplayIdForTransfers : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Entries_DisplayId",
|
||||
table: "Entries");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Entries_DisplayId",
|
||||
table: "Entries",
|
||||
column: "DisplayId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Entries_DisplayId",
|
||||
table: "Entries");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Entries_DisplayId",
|
||||
table: "Entries",
|
||||
column: "DisplayId",
|
||||
unique: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,8 +80,7 @@ namespace Duempelkas.Infrastructure.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DisplayId")
|
||||
.IsUnique();
|
||||
b.HasIndex("DisplayId");
|
||||
|
||||
b.HasIndex("TransferLinkId");
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ public class EntryConfiguration : IEntityTypeConfiguration<Entry>
|
||||
{
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.DisplayId).IsRequired().HasMaxLength(20);
|
||||
builder.HasIndex(e => e.DisplayId).IsUnique();
|
||||
builder.HasIndex(e => e.DisplayId);
|
||||
builder.Property(e => e.Title).IsRequired().HasMaxLength(500);
|
||||
builder.Property(e => e.Amount).HasColumnType("decimal(18,2)");
|
||||
builder.Property(e => e.Type).HasConversion<int>();
|
||||
|
||||
@@ -63,7 +63,7 @@ public class EntryService : IEntryService
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var displayId = await GenerateDisplayIdAsync(db, accountId, date.Year);
|
||||
var displayId = await GenerateDisplayIdAsync(db, date.Year);
|
||||
|
||||
var entry = new Entry
|
||||
{
|
||||
@@ -88,12 +88,12 @@ public class EntryService : IEntryService
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
|
||||
var sourceDisplayId = await GenerateDisplayIdAsync(db, sourceAccountId, date.Year);
|
||||
var transferDisplayId = await GenerateDisplayIdAsync(db, date.Year);
|
||||
|
||||
var sourceEntry = new Entry
|
||||
{
|
||||
AccountId = sourceAccountId,
|
||||
DisplayId = sourceDisplayId,
|
||||
DisplayId = transferDisplayId,
|
||||
Type = EntryType.Expense,
|
||||
Date = date,
|
||||
Title = title,
|
||||
@@ -103,12 +103,10 @@ public class EntryService : IEntryService
|
||||
db.Entries.Add(sourceEntry);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var targetDisplayId = await GenerateDisplayIdAsync(db, targetAccountId, date.Year);
|
||||
|
||||
var targetEntry = new Entry
|
||||
{
|
||||
AccountId = targetAccountId,
|
||||
DisplayId = targetDisplayId,
|
||||
DisplayId = transferDisplayId,
|
||||
Type = EntryType.Income,
|
||||
Date = date,
|
||||
Title = title,
|
||||
@@ -234,13 +232,12 @@ public class EntryService : IEntryService
|
||||
if (otherEntry.AccountId != newLinkedAccountId)
|
||||
{
|
||||
otherEntry.AccountId = newLinkedAccountId;
|
||||
otherEntry.DisplayId = await GenerateDisplayIdAsync(db, newLinkedAccountId, date.Year);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task<string> GenerateDisplayIdAsync(FinanceDbContext db, int accountId, int year)
|
||||
private static async Task<string> GenerateDisplayIdAsync(FinanceDbContext db, int year)
|
||||
{
|
||||
var prefix = $"{year}-";
|
||||
var maxDisplayId = await db.Entries
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Globalization;
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Duempelkas.Domain.Entities;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Duempelkas.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -138,6 +139,182 @@ public class PdfStatementService : IPdfStatementService
|
||||
return document.GeneratePdf();
|
||||
}
|
||||
|
||||
public async Task<byte[]> GenerateDashboardStatementAsync()
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var accounts = await db.Accounts
|
||||
.OrderBy(a => a.Name)
|
||||
.ToListAsync();
|
||||
|
||||
var accountById = accounts.ToDictionary(a => a.Id);
|
||||
var clubName = await _settingsService.GetClubNameAsync() ?? "Mein Verein";
|
||||
|
||||
var transfers = await db.TransferLinks
|
||||
.Include(tl => tl.SourceEntry).ThenInclude(e => e.Account)
|
||||
.Include(tl => tl.TargetEntry).ThenInclude(e => e.Account)
|
||||
.ToListAsync();
|
||||
|
||||
var transferByEntryId = new Dictionary<int, TransferLink>();
|
||||
foreach (var transfer in transfers)
|
||||
{
|
||||
transferByEntryId[transfer.SourceEntryId] = transfer;
|
||||
transferByEntryId[transfer.TargetEntryId] = transfer;
|
||||
}
|
||||
|
||||
var entries = await db.Entries
|
||||
.Include(e => e.Account)
|
||||
.Where(e => !e.IsDeleted)
|
||||
.OrderBy(e => e.Date)
|
||||
.ThenBy(e => e.CreatedUtc)
|
||||
.ToListAsync();
|
||||
|
||||
var movementByAccountId = entries
|
||||
.GroupBy(e => e.AccountId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount));
|
||||
|
||||
var totalClubBalance = accounts.Sum(a => a.CarryoverBalance + movementByAccountId.GetValueOrDefault(a.Id));
|
||||
|
||||
var processedTransferIds = new HashSet<int>();
|
||||
var rows = new List<DashboardStatementRow>();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (!transferByEntryId.TryGetValue(entry.Id, out var transfer))
|
||||
{
|
||||
rows.Add(new DashboardStatementRow(
|
||||
entry.DisplayId,
|
||||
entry.Date,
|
||||
entry.Title,
|
||||
new Dictionary<int, decimal>
|
||||
{
|
||||
[entry.AccountId] = entry.Type == EntryType.Income ? entry.Amount : -entry.Amount
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!processedTransferIds.Add(transfer.Id))
|
||||
continue;
|
||||
|
||||
var source = transfer.SourceEntry;
|
||||
var target = transfer.TargetEntry;
|
||||
|
||||
if (source.IsDeleted || target.IsDeleted)
|
||||
continue;
|
||||
|
||||
var transferTitle = $"{source.Title} ({source.Account.Name} -> {target.Account.Name})";
|
||||
|
||||
rows.Add(new DashboardStatementRow(
|
||||
source.DisplayId,
|
||||
source.Date,
|
||||
transferTitle,
|
||||
new Dictionary<int, decimal>
|
||||
{
|
||||
[source.AccountId] = -source.Amount,
|
||||
[target.AccountId] = target.Amount
|
||||
}));
|
||||
}
|
||||
|
||||
rows = rows
|
||||
.OrderBy(r => ParseDisplayIdYear(r.DisplayId))
|
||||
.ThenBy(r => ParseDisplayIdSequence(r.DisplayId))
|
||||
.ThenBy(r => r.Date)
|
||||
.ThenBy(r => r.Title)
|
||||
.ToList();
|
||||
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4.Landscape());
|
||||
page.MarginHorizontal(24);
|
||||
page.MarginVertical(20);
|
||||
page.DefaultTextStyle(x => x.FontSize(9));
|
||||
|
||||
page.Header().Column(col =>
|
||||
{
|
||||
col.Item().Text(clubName).Bold().FontSize(18);
|
||||
col.Item().Text("Übersicht aller Konten").FontSize(13).FontColor(Colors.Grey.Darken1);
|
||||
col.Item().PaddingTop(2).Text($"Gesamtvermögen: {FormatCurrency(totalClubBalance)}")
|
||||
.FontSize(10)
|
||||
.SemiBold()
|
||||
.FontColor(totalClubBalance >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
|
||||
col.Item().PaddingTop(4).Text($"Erstellt am: {DateTime.Now:dd.MM.yyyy}").FontSize(8).FontColor(Colors.Grey.Medium);
|
||||
col.Item().PaddingTop(8).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
|
||||
});
|
||||
|
||||
page.Content().PaddingTop(10).Column(content =>
|
||||
{
|
||||
content.Item().Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.ConstantColumn(70);
|
||||
columns.ConstantColumn(70);
|
||||
columns.RelativeColumn(2);
|
||||
foreach (var _ in accounts)
|
||||
columns.RelativeColumn();
|
||||
});
|
||||
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
|
||||
.PaddingBottom(4).Text("LFDNR").SemiBold();
|
||||
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
|
||||
.PaddingBottom(4).Text("Datum").SemiBold();
|
||||
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
|
||||
.PaddingBottom(4).Text("Titel").SemiBold();
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
|
||||
.PaddingBottom(4).AlignRight().Text(account.Name).SemiBold();
|
||||
}
|
||||
});
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
BodyCell(table, row.DisplayId);
|
||||
BodyCell(table, row.Date.ToString("dd.MM.yyyy"));
|
||||
BodyCell(table, row.Title);
|
||||
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
if (row.AmountByAccountId.TryGetValue(account.Id, out var amount))
|
||||
{
|
||||
BodyAmountCell(table, amount);
|
||||
}
|
||||
else
|
||||
{
|
||||
BodyCell(table, string.Empty, alignRight: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
content.Item().PaddingTop(8).AlignRight().Text(text =>
|
||||
{
|
||||
text.Span("Gesamtvermögen: ").SemiBold();
|
||||
text.Span(FormatCurrency(totalClubBalance))
|
||||
.SemiBold()
|
||||
.FontColor(totalClubBalance >= 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 =>
|
||||
@@ -152,4 +329,40 @@ public class PdfStatementService : IPdfStatementService
|
||||
{
|
||||
return amount.ToString("N2", DeLocale) + " €";
|
||||
}
|
||||
|
||||
private static void BodyCell(TableDescriptor table, string text, bool alignRight = false)
|
||||
{
|
||||
var cell = table.Cell()
|
||||
.BorderBottom(0.5f)
|
||||
.BorderColor(Colors.Grey.Lighten2)
|
||||
.PaddingVertical(3);
|
||||
|
||||
var content = alignRight ? cell.AlignRight() : cell;
|
||||
content.Text(text);
|
||||
}
|
||||
|
||||
private static void BodyAmountCell(TableDescriptor table, decimal amount)
|
||||
{
|
||||
table.Cell()
|
||||
.BorderBottom(0.5f)
|
||||
.BorderColor(Colors.Grey.Lighten2)
|
||||
.PaddingVertical(3)
|
||||
.AlignRight()
|
||||
.Text($"{(amount >= 0 ? "+" : "−")}{FormatCurrency(Math.Abs(amount))}")
|
||||
.FontColor(amount >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
|
||||
}
|
||||
|
||||
private static int ParseDisplayIdYear(string displayId)
|
||||
{
|
||||
var parts = displayId.Split('-');
|
||||
return parts.Length == 2 && int.TryParse(parts[0], out var year) ? year : int.MaxValue;
|
||||
}
|
||||
|
||||
private static int ParseDisplayIdSequence(string displayId)
|
||||
{
|
||||
var parts = displayId.Split('-');
|
||||
return parts.Length == 2 && int.TryParse(parts[1], out var seq) ? seq : int.MaxValue;
|
||||
}
|
||||
|
||||
private sealed record DashboardStatementRow(string DisplayId, DateTime Date, string Title, Dictionary<int, decimal> AmountByAccountId);
|
||||
}
|
||||
|
||||
132
tests/Duempelkas.Tests/DashboardPdfStatementServiceTests.cs
Normal file
132
tests/Duempelkas.Tests/DashboardPdfStatementServiceTests.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.Domain.Entities;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Duempelkas.Infrastructure.Persistence;
|
||||
using Duempelkas.Infrastructure.Services;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class DashboardPdfStatementServiceTests : IDisposable
|
||||
{
|
||||
private readonly FinanceDbContext _db;
|
||||
private readonly PdfStatementService _pdfService;
|
||||
private readonly string _connectionString = $"Data Source=duempelkas-dashboard-pdf-tests-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||
|
||||
public DashboardPdfStatementServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<FinanceDbContext>()
|
||||
.UseSqlite(_connectionString)
|
||||
.Options;
|
||||
|
||||
_db = new FinanceDbContext(options);
|
||||
_db.Database.OpenConnection();
|
||||
_db.Database.EnsureCreated();
|
||||
|
||||
var dbFactory = new TestDbContextFactory(options);
|
||||
var entryService = new EntryService(dbFactory);
|
||||
var balanceQueryService = new BalanceQueryService(dbFactory);
|
||||
var settingsService = new FixedSettingsService("Testverein");
|
||||
|
||||
_pdfService = new PdfStatementService(dbFactory, entryService, balanceQueryService, settingsService);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateDashboardStatementAsync_WithBookingsAndTransfer_ReturnsPdf()
|
||||
{
|
||||
var barkasse = new Account { Name = "Barkasse" };
|
||||
var girokonto = new Account { Name = "Girokonto" };
|
||||
_db.Accounts.AddRange(barkasse, girokonto);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_db.Entries.Add(new Entry
|
||||
{
|
||||
AccountId = barkasse.Id,
|
||||
DisplayId = "2026-001",
|
||||
Type = EntryType.Income,
|
||||
Date = new DateTime(2026, 1, 12),
|
||||
Title = "Einkuenfte Sommerfest",
|
||||
Amount = 604.60m
|
||||
});
|
||||
|
||||
var transferExpense = new Entry
|
||||
{
|
||||
AccountId = barkasse.Id,
|
||||
DisplayId = "2026-002",
|
||||
Type = EntryType.Expense,
|
||||
Date = new DateTime(2026, 2, 16),
|
||||
Title = "Einzahlung",
|
||||
Amount = 600.00m
|
||||
};
|
||||
|
||||
var transferIncome = new Entry
|
||||
{
|
||||
AccountId = girokonto.Id,
|
||||
DisplayId = "2026-002",
|
||||
Type = EntryType.Income,
|
||||
Date = new DateTime(2026, 2, 16),
|
||||
Title = "Einzahlung",
|
||||
Amount = 600.00m
|
||||
};
|
||||
|
||||
_db.Entries.AddRange(transferExpense, transferIncome);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var transferLink = new TransferLink
|
||||
{
|
||||
SourceEntryId = transferExpense.Id,
|
||||
TargetEntryId = transferIncome.Id,
|
||||
Note = "Umbuchung"
|
||||
};
|
||||
|
||||
_db.TransferLinks.Add(transferLink);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
transferExpense.TransferLinkId = transferLink.Id;
|
||||
transferIncome.TransferLinkId = transferLink.Id;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var pdf = await _pdfService.GenerateDashboardStatementAsync();
|
||||
|
||||
pdf.Should().NotBeNull();
|
||||
pdf.Length.Should().BeGreaterThan(1000);
|
||||
System.Text.Encoding.ASCII.GetString(pdf.Take(4).ToArray()).Should().Be("%PDF");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db.Database.CloseConnection();
|
||||
_db.Dispose();
|
||||
}
|
||||
|
||||
private sealed class TestDbContextFactory : IDbContextFactory<FinanceDbContext>
|
||||
{
|
||||
private readonly DbContextOptions<FinanceDbContext> _options;
|
||||
|
||||
public TestDbContextFactory(DbContextOptions<FinanceDbContext> options)
|
||||
{
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public FinanceDbContext CreateDbContext() => new(_options);
|
||||
|
||||
public Task<FinanceDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new FinanceDbContext(_options));
|
||||
}
|
||||
|
||||
private sealed class FixedSettingsService : ISettingsService
|
||||
{
|
||||
private readonly string? _clubName;
|
||||
|
||||
public FixedSettingsService(string? clubName)
|
||||
{
|
||||
_clubName = clubName;
|
||||
}
|
||||
|
||||
public Task<string?> GetClubNameAsync() => Task.FromResult(_clubName);
|
||||
|
||||
public Task SetClubNameAsync(string? clubName) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
123
tests/Duempelkas.Tests/EntryServiceBookingTests.cs
Normal file
123
tests/Duempelkas.Tests/EntryServiceBookingTests.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using Duempelkas.Domain.Entities;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Duempelkas.Infrastructure.Persistence;
|
||||
using Duempelkas.Infrastructure.Services;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class EntryServiceBookingTests : IDisposable
|
||||
{
|
||||
private readonly FinanceDbContext _db;
|
||||
private readonly EntryService _entryService;
|
||||
private readonly string _connectionString = $"Data Source=duempelkas-entry-tests-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||
|
||||
public EntryServiceBookingTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<FinanceDbContext>()
|
||||
.UseSqlite(_connectionString)
|
||||
.Options;
|
||||
|
||||
_db = new FinanceDbContext(options);
|
||||
_db.Database.OpenConnection();
|
||||
_db.Database.EnsureCreated();
|
||||
|
||||
var dbFactory = new TestDbContextFactory(options);
|
||||
_entryService = new EntryService(dbFactory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateEntry_AssignsSequentialDisplayIds_PerYear()
|
||||
{
|
||||
var accountA = new Account { Name = "Konto A" };
|
||||
var accountB = new Account { Name = "Konto B" };
|
||||
_db.Accounts.AddRange(accountA, accountB);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var first = await _entryService.CreateEntryAsync(accountA.Id, EntryType.Income, new DateTime(2026, 1, 10), "Einnahme A", 100m);
|
||||
var second = await _entryService.CreateEntryAsync(accountB.Id, EntryType.Expense, new DateTime(2026, 1, 11), "Ausgabe B", 20m);
|
||||
var third = await _entryService.CreateEntryAsync(accountA.Id, EntryType.Income, new DateTime(2027, 1, 1), "Neues Jahr", 5m);
|
||||
|
||||
first.DisplayId.Should().Be("2026-001");
|
||||
second.DisplayId.Should().Be("2026-002");
|
||||
third.DisplayId.Should().Be("2027-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateEntry_AfterTransfer_UsesNextDisplayId()
|
||||
{
|
||||
var source = new Account { Name = "Barkasse" };
|
||||
var target = new Account { Name = "Girokonto" };
|
||||
_db.Accounts.AddRange(source, target);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _entryService.CreateTransferAsync(source.Id, target.Id, new DateTime(2026, 3, 15), "Umbuchung", 500m);
|
||||
var booking = await _entryService.CreateEntryAsync(source.Id, EntryType.Income, new DateTime(2026, 3, 16), "Einzahlung", 50m);
|
||||
|
||||
booking.DisplayId.Should().Be("2026-002");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateEntry_UpdatesBookingFields()
|
||||
{
|
||||
var account = new Account { Name = "Konto" };
|
||||
_db.Accounts.Add(account);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var entry = await _entryService.CreateEntryAsync(account.Id, EntryType.Expense, new DateTime(2026, 2, 2), "Alt", 10m);
|
||||
|
||||
await _entryService.UpdateEntryAsync(entry.Id, new DateTime(2026, 2, 3), "Neu", 25m);
|
||||
|
||||
_db.ChangeTracker.Clear();
|
||||
var updated = await _db.Entries.SingleAsync(e => e.Id == entry.Id);
|
||||
|
||||
updated.Date.Should().Be(new DateTime(2026, 2, 3));
|
||||
updated.Title.Should().Be("Neu");
|
||||
updated.Amount.Should().Be(25m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAndRestoreEntry_TogglesSoftDelete_ForBookingOnly()
|
||||
{
|
||||
var account = new Account { Name = "Konto" };
|
||||
_db.Accounts.Add(account);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var entry = await _entryService.CreateEntryAsync(account.Id, EntryType.Expense, new DateTime(2026, 2, 2), "Buchung", 10m);
|
||||
|
||||
await _entryService.DeleteEntryAsync(entry.Id);
|
||||
|
||||
_db.ChangeTracker.Clear();
|
||||
var deleted = await _db.Entries.SingleAsync(e => e.Id == entry.Id);
|
||||
deleted.IsDeleted.Should().BeTrue();
|
||||
|
||||
await _entryService.RestoreEntryAsync(entry.Id);
|
||||
|
||||
_db.ChangeTracker.Clear();
|
||||
var restored = await _db.Entries.SingleAsync(e => e.Id == entry.Id);
|
||||
restored.IsDeleted.Should().BeFalse();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db.Database.CloseConnection();
|
||||
_db.Dispose();
|
||||
}
|
||||
|
||||
private sealed class TestDbContextFactory : IDbContextFactory<FinanceDbContext>
|
||||
{
|
||||
private readonly DbContextOptions<FinanceDbContext> _options;
|
||||
|
||||
public TestDbContextFactory(DbContextOptions<FinanceDbContext> options)
|
||||
{
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public FinanceDbContext CreateDbContext() => new(_options);
|
||||
|
||||
public Task<FinanceDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new FinanceDbContext(_options));
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,12 @@ public class TransferServiceTests : IDisposable
|
||||
private readonly FinanceDbContext _db;
|
||||
private readonly EntryService _entryService;
|
||||
private readonly BalanceQueryService _balanceQueryService;
|
||||
private readonly string _connectionString = $"Data Source=duempelkas-transfer-tests-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||
|
||||
public TransferServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<FinanceDbContext>()
|
||||
.UseSqlite("Data Source=duempelkas-transfer-tests;Mode=Memory;Cache=Shared")
|
||||
.UseSqlite(_connectionString)
|
||||
.Options;
|
||||
|
||||
_db = new FinanceDbContext(options);
|
||||
@@ -52,7 +53,8 @@ public class TransferServiceTests : IDisposable
|
||||
var targetEntry = entries.Single(e => e.AccountId == accountB.Id);
|
||||
targetEntry.Type.Should().Be(EntryType.Income);
|
||||
targetEntry.Amount.Should().Be(100.00m);
|
||||
targetEntry.DisplayId.Should().Be("2026-002");
|
||||
targetEntry.DisplayId.Should().Be("2026-001");
|
||||
targetEntry.DisplayId.Should().Be(sourceEntry.DisplayId);
|
||||
|
||||
var links = await _db.TransferLinks.ToListAsync();
|
||||
links.Should().HaveCount(1);
|
||||
@@ -105,6 +107,29 @@ public class TransferServiceTests : IDisposable
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTransfer_ChangeLinkedAccount_KeepsSharedDisplayId()
|
||||
{
|
||||
var source = new Account { Name = "Barkasse" };
|
||||
var initialTarget = new Account { Name = "Girokonto" };
|
||||
var newTarget = new Account { Name = "Sparkonto" };
|
||||
_db.Accounts.AddRange(source, initialTarget, newTarget);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _entryService.CreateTransferAsync(source.Id, initialTarget.Id, new DateTime(2026, 3, 15), "Umbuchung", 500.00m);
|
||||
var sourceEntry = await _db.Entries.SingleAsync(e => e.AccountId == source.Id);
|
||||
|
||||
await _entryService.UpdateTransferAsync(sourceEntry.Id, newTarget.Id, new DateTime(2026, 3, 16), "Umbuchung angepasst", 550.00m);
|
||||
|
||||
_db.ChangeTracker.Clear();
|
||||
|
||||
var updatedSourceEntry = await _db.Entries.SingleAsync(e => e.AccountId == source.Id);
|
||||
var updatedTargetEntry = await _db.Entries.SingleAsync(e => e.AccountId == newTarget.Id);
|
||||
|
||||
updatedSourceEntry.DisplayId.Should().Be("2026-001");
|
||||
updatedTargetEntry.DisplayId.Should().Be(updatedSourceEntry.DisplayId);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db.Database.CloseConnection();
|
||||
|
||||
@@ -13,11 +13,12 @@ public class BalanceCalculationTests : IDisposable
|
||||
private readonly FinanceDbContext _db;
|
||||
private readonly BalanceQueryService _balanceQueryService;
|
||||
private readonly EntryService _entryService;
|
||||
private readonly string _connectionString = $"Data Source=duempelkas-balance-tests-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||
|
||||
public BalanceCalculationTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<FinanceDbContext>()
|
||||
.UseSqlite("Data Source=duempelkas-balance-tests;Mode=Memory;Cache=Shared")
|
||||
.UseSqlite(_connectionString)
|
||||
.Options;
|
||||
|
||||
_db = new FinanceDbContext(options);
|
||||
|
||||
Reference in New Issue
Block a user