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; using QuestPDF.Fluent; using QuestPDF.Helpers; using QuestPDF.Infrastructure; namespace Duempelkas.Infrastructure.Services; public class PdfStatementService : IPdfStatementService { static PdfStatementService() { QuestPDF.Settings.License = LicenseType.Community; } private readonly IDbContextFactory _dbFactory; private readonly IEntryService _entryService; private readonly IBalanceQueryService _balanceQueryService; private readonly ISettingsService _settingsService; private static readonly CultureInfo DeLocale = new("de-DE"); public PdfStatementService(IDbContextFactory dbFactory, IEntryService entryService, IBalanceQueryService balanceQueryService, ISettingsService settingsService) { _dbFactory = dbFactory; _entryService = entryService; _balanceQueryService = balanceQueryService; _settingsService = settingsService; } public Task GenerateStatementAsync(int accountId, bool currentYearOnly) { var year = currentYearOnly ? DateTime.Now.Year : (int?)null; return GenerateStatementAsync(accountId, year); } public async Task GenerateStatementAsync(int accountId, int? year) { await using var db = await _dbFactory.CreateDbContextAsync(); var account = await db.Accounts.FindAsync(accountId) ?? throw new InvalidOperationException($"Konto {accountId} nicht gefunden."); var entries = await _entryService.GetEntriesAsync(accountId, year); var balance = await _balanceQueryService.GetAccountBalanceAsync(accountId, year); var clubName = await _settingsService.GetClubNameAsync() ?? "Mein Verein"; var title = year.HasValue ? $"{account.Name} – Auszug {year.Value}" : $"{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(clubName).Bold().FontSize(20); col.Item().Text(title).FontSize(14).FontColor(Colors.Grey.Darken1); col.Item().Text(year.HasValue ? $"Zeitraum: 01.01.{year} bis 31.12.{year}" : "Zeitraum: Alle Jahre").FontSize(9); col.Item().Text("Nur wirksame Buchungen. Umbuchungen zählen im einzelnen Konto als Zu- oder Abgang.").FontSize(8); 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().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.Where(e => !e.IsDeleted)) { 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 => { var incomeValue = year.HasValue ? balance.CurrentYearIncome : balance.TotalIncome; var expenseValue = year.HasValue ? balance.CurrentYearExpense : balance.TotalExpense; if (year.HasValue) { SummaryRow(summaryCol, $"Übertrag von {year.Value - 1}:", balance.CarryoverBalance); } else { SummaryRow(summaryCol, "Anfangsbestand:", balance.CarryoverBalance); } SummaryRow(summaryCol, year.HasValue ? "Einnahmen:" : "Einnahmen gesamt:", incomeValue); SummaryRow(summaryCol, year.HasValue ? "Ausgaben:" : "Ausgaben gesamt:", -expenseValue); summaryCol.Item().PaddingTop(5).Row(row => { row.RelativeItem().Text(year.HasValue ? $"Kontostand am 31.12.{year}:" : "Kontostand nach allen Buchungen:").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(); } public Task GenerateDashboardStatementAsync() { return GenerateDashboardStatementAsync(null); } public async Task GenerateDashboardStatementAsync(int? year) { 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(); foreach (var transfer in transfers) { transferByEntryId[transfer.SourceEntryId] = transfer; transferByEntryId[transfer.TargetEntryId] = transfer; } var entriesQuery = db.Entries .Include(e => e.Account) .Where(e => !e.IsDeleted); if (year.HasValue) { entriesQuery = entriesQuery.Where(e => e.Date.Year == year.Value); } var entries = await entriesQuery .OrderBy(e => e.Date) .ThenBy(e => e.CreatedUtc) .ToListAsync(); Dictionary balanceByAccountId; Dictionary? carryoverByAccountId = null; if (year.HasValue) { var movementBeforeYearByAccountId = await db.Entries .Where(e => !e.IsDeleted && e.Date.Year < year.Value) .GroupBy(e => e.AccountId) .ToDictionaryAsync( g => g.Key, g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount)); var movementInYearByAccountId = entries .GroupBy(e => e.AccountId) .ToDictionary( g => g.Key, g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount)); carryoverByAccountId = accounts .ToDictionary( a => a.Id, a => a.CarryoverBalance + movementBeforeYearByAccountId.GetValueOrDefault(a.Id)); balanceByAccountId = accounts .ToDictionary( a => a.Id, a => a.CarryoverBalance + movementBeforeYearByAccountId.GetValueOrDefault(a.Id) + movementInYearByAccountId.GetValueOrDefault(a.Id)); } else { var movementByAccountId = entries .GroupBy(e => e.AccountId) .ToDictionary( g => g.Key, g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount)); balanceByAccountId = accounts .ToDictionary( a => a.Id, a => a.CarryoverBalance + movementByAccountId.GetValueOrDefault(a.Id)); } var totalClubBalance = balanceByAccountId.Values.Sum(); var processedTransferIds = new HashSet(); var rows = new List(); 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 { [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 { [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); var headerTitle = year.HasValue ? $"Übersicht aller Konten ({year.Value})" : "Übersicht aller Konten"; col.Item().Text(headerTitle).FontSize(13).FontColor(Colors.Grey.Darken1); col.Item().Text(year.HasValue ? $"Zeitraum: 01.01.{year} bis 31.12.{year}; Endstände am 31.12.{year}." : "Zeitraum: Alle Jahre; Endstände nach allen Buchungen.").FontSize(9); col.Item().Text("Nur wirksame Buchungen. Interne Umbuchungen erscheinen einmal mit Minus und Plus; die Vereinssumme bleibt unverändert.").FontSize(8); 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(); } }); { BodyCell(table, string.Empty); BodyCell(table, string.Empty); BodyCell(table, year.HasValue ? $"Übertrag von {year.Value - 1}" : "Anfangsbestand", alignRight: true); foreach (var account in accounts) { BodyAmountCell(table, carryoverByAccountId?.GetValueOrDefault(account.Id) ?? account.CarryoverBalance); } } 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); } } } // Add one summary line with the final balance for each account column. FooterCell(table, string.Empty); FooterCell(table, string.Empty); FooterCell(table, "Kontostand", alignRight: true); foreach (var account in accounts) { FooterAmountCell(table, balanceByAccountId.GetValueOrDefault(account.Id)); } }); 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 => { 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) + " €"; } 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 void FooterCell(TableDescriptor table, string text, bool alignRight = false) { var cell = table.Cell() .BorderTop(1) .BorderColor(Colors.Grey.Darken1) .PaddingTop(5) .PaddingBottom(2); var content = alignRight ? cell.AlignRight() : cell; content.Text(text).SemiBold(); } private static void FooterAmountCell(TableDescriptor table, decimal amount) { table.Cell() .BorderTop(1) .BorderColor(Colors.Grey.Darken1) .PaddingTop(5) .PaddingBottom(2) .AlignRight() .Text(FormatCurrency(amount)) .SemiBold() .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 AmountByAccountId); }