Files
duempelkas/src/Duempelkas.Infrastructure/Services/PdfStatementService.cs
T

489 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<FinanceDbContext> _dbFactory;
private readonly IEntryService _entryService;
private readonly IBalanceQueryService _balanceQueryService;
private readonly ISettingsService _settingsService;
private static readonly CultureInfo DeLocale = new("de-DE");
public PdfStatementService(IDbContextFactory<FinanceDbContext> dbFactory, IEntryService entryService, IBalanceQueryService balanceQueryService, ISettingsService settingsService)
{
_dbFactory = dbFactory;
_entryService = entryService;
_balanceQueryService = balanceQueryService;
_settingsService = settingsService;
}
public Task<byte[]> GenerateStatementAsync(int accountId, bool currentYearOnly)
{
var year = currentYearOnly ? DateTime.Now.Year : (int?)null;
return GenerateStatementAsync(accountId, year);
}
public async Task<byte[]> 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<byte[]> GenerateDashboardStatementAsync()
{
return GenerateDashboardStatementAsync(null);
}
public async Task<byte[]> 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<int, TransferLink>();
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<int, decimal> balanceByAccountId;
Dictionary<int, decimal>? 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<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);
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<int, decimal> AmountByAccountId);
}