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:
2026-03-31 17:12:57 +02:00
parent 8fbb8b8ea2
commit c3d68020d5
19 changed files with 1450 additions and 0 deletions
@@ -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) + " €";
}
}