Accounting A-4 (PDF): neutrale Abrechnung als PDF via PDFsharp/MigraDoc (MIT)

Vervollstaendigt den Export (CSV war A-2) um PDF - laenderneutral, KEINE steuerliche Einordnung.
PDFsharp/MigraDoc-GDI 6.2.4 (echte MIT ohne Umsatzschwelle; GDI-Variante nutzt System-Fonts auf
Windows/WinForms; keine NU1701-Transitiven - bewusst NICHT QuestPDF).

- PdfExporter (Logic): Kopf (Konto/Zeitraum/Waehrung/Datum) + Aggregat-Tabelle + Monatsvergleich +
  Transaktionsliste + Methodik-/Nachweis-Seite (append-only-Quelle, Cash-Basis, 'keine Steuerberatung',
  SHA-256-Daten-Hash fuer Reproduzierbarkeit). Betraege in Anzeige-Waehrung (Faktor uebergeben ->
  Services-unabhaengig). Landscape.
- UI: Button 'PDF-Export' (via Designer) neben CSV; Export via SaveFileDialog in gewaehlter Waehrung.
- Test: PdfExporter erzeugt valide PDF-Bytes (%PDF-Signatur) headless -> MigraDoc-Rendering verifiziert.

Build 0 Fehler, 386 Tests gruen, --smoke-ui alle 6 Views gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-20 15:10:52 +02:00
co-authored by Claude Opus 4.8
parent 42a599a3a3
commit ca0d4ceed0
5 changed files with 233 additions and 1 deletions
@@ -0,0 +1,166 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using MigraDoc.DocumentObjectModel;
using MigraDoc.DocumentObjectModel.Tables;
using MigraDoc.Rendering;
using PolyTrader.Modules.Accounting.Models;
namespace PolyTrader.Modules.Accounting.Logic
{
/// <summary>
/// PDF-Export der neutralen Abrechnung (A-4) via PDFsharp/MigraDoc (MIT, keine Umsatzschwelle).
/// Kopf + Aggregat-Tabelle + Monatsvergleich + Transaktionsliste + Methodik-/Nachweis-Seite.
/// Länderneutral (KEINE steuerliche Einordnung die käme aus A-3). Beträge in der übergebenen
/// Anzeige-Währung (USDC/USD/EUR); die Umrechnung erfolgt außerhalb (Services), hier nur Faktor.
/// </summary>
public static class PdfExporter
{
public static byte[] Render(
PeriodStatement statement,
IReadOnlyList<PeriodStatement> monthly,
IReadOnlyList<LedgerEntry> periodEntries,
string currencyCode, decimal currencyFactor, string currencyNote)
{
decimal V(decimal usdc) => Math.Round(usdc * currencyFactor, 2, MidpointRounding.AwayFromZero);
string M(decimal usdc) => V(usdc).ToString("N2") + " " + currencyCode;
var doc = new Document();
doc.Info.Title = "Buchhalterische Abrechnung";
var style = doc.Styles["Normal"];
style.Font.Name = "Segoe UI";
style.Font.Size = 9;
var section = doc.AddSection();
section.PageSetup.Orientation = MigraDoc.DocumentObjectModel.Orientation.Landscape;
section.PageSetup.LeftMargin = Unit.FromCentimeter(1.5);
section.PageSetup.RightMargin = Unit.FromCentimeter(1.5);
// ---- Kopf ----
var head = section.AddParagraph("Buchhalterische Abrechnung (neutral)");
head.Format.Font.Size = 16; head.Format.Font.Bold = true;
head.Format.SpaceAfter = Unit.FromMillimeter(2);
var meta = section.AddParagraph();
meta.Format.SpaceAfter = Unit.FromMillimeter(4);
meta.AddText($"Konto: {(statement.AccountId?.ToString() ?? "alle Live-Konten")}");
meta.AddLineBreak();
meta.AddText($"Zeitraum: {statement.From:yyyy-MM-dd} bis {statement.To:yyyy-MM-dd}");
meta.AddLineBreak();
meta.AddText($"Währung: {currencyCode} ({currencyNote})");
meta.AddLineBreak();
meta.AddText($"Erstellt: {DateTime.Now:yyyy-MM-dd HH:mm}");
// ---- Aggregat ----
AddSectionTitle(section, "Zusammenfassung");
var agg = NewTable(section, new[] { 8.0, 6.0 });
AddKeyValue(agg, "Anfangssaldo", M(statement.OpeningBalanceUsdc));
AddKeyValue(agg, "Einzahlungen", M(statement.Deposits));
AddKeyValue(agg, "Auszahlungen", M(statement.Withdrawals));
AddKeyValue(agg, "Handelsvolumen", M(statement.TradeVolume));
AddKeyValue(agg, "Redeems", M(statement.Redeems));
AddKeyValue(agg, "Rewards", M(statement.Rewards));
AddKeyValue(agg, "Fees", M(statement.Fees));
AddKeyValue(agg, "Netto-Handelsergebnis (Cash-Basis)", M(statement.NetTradingResultUsdc));
AddKeyValue(agg, "Endsaldo", M(statement.ClosingBalanceUsdc));
AddKeyValue(agg, "Anzahl Trades", statement.TradeCount.ToString());
AddKeyValue(agg, "Anzahl Buchungen", statement.EntryCount.ToString());
// ---- Monatsvergleich ----
if (monthly.Count > 1)
{
AddSectionTitle(section, "Monatsvergleich");
var mt = NewTable(section, new[] { 3.0, 3.5, 3.5, 3.5, 3.0, 3.0, 3.5, 3.5, 2.0 });
HeaderRow(mt, "Monat", "Anfang", "Einz.", "Ausz.", "Volumen", "Rewards", "Fees", "Ergebnis", "Endsaldo");
foreach (var m in monthly)
DataRow(mt, m.From.ToString("yyyy-MM"), M(m.OpeningBalanceUsdc), M(m.Deposits), M(m.Withdrawals),
M(m.TradeVolume), M(m.Rewards), M(m.Fees), M(m.NetTradingResultUsdc), M(m.ClosingBalanceUsdc));
}
// ---- Transaktionsliste ----
AddSectionTitle(section, $"Transaktionen ({periodEntries.Count})");
var lt = NewTable(section, new[] { 3.5, 3.0, 2.0, 6.0, 2.5, 2.5, 2.5, 5.5 });
HeaderRow(lt, "Zeit (UTC)", "Typ", "Side", "Markt", "Size", "Preis", "Netto", "TxHash");
foreach (var e in periodEntries.OrderBy(e => e.Timestamp))
DataRow(lt, e.Timestamp.ToString("yyyy-MM-dd HH:mm"), e.EventType.ToString(), e.Side,
Trim(e.MarketSlug, 40), e.Size.ToString("0.###"), e.PriceUsdc.ToString("0.###"),
M(e.NetUsdc), Trim(e.TxHash, 22));
// ---- Methodik / Nachweis ----
AddSectionTitle(section, "Methodik & Nachweis");
var method = section.AddParagraph();
method.Format.Font.Size = 8;
method.AddText("• Buchungsgrundlage sind ausschließlich unabhängige Polymarket-/On-Chain-Abrufe (nicht die Trading-DB), append-only.");
method.AddLineBreak();
method.AddText("• Netto-Handelsergebnis ist Cash-Basis (Erlöse Kosten Fees) und schließt Ein-/Auszahlungen aus.");
method.AddLineBreak();
method.AddText($"• Währungsumrechnung: {currencyNote} USDC ist die native Buchungswährung.");
method.AddLineBreak();
method.AddText("• Dies ist eine neutrale, prüfbare Aufstellung und KEINE Steuerberatung. Eine steuerliche Einordnung erfolgt getrennt.");
method.AddLineBreak();
method.AddText($"• Daten-Hash (SHA-256 über Ledger+Aggregat): {DataHash(statement, periodEntries)}");
var renderer = new PdfDocumentRenderer { Document = doc };
renderer.RenderDocument();
using var ms = new MemoryStream();
renderer.PdfDocument.Save(ms, false);
return ms.ToArray();
}
// ---- MigraDoc-Helfer ----
private static void AddSectionTitle(Section s, string text)
{
var p = s.AddParagraph(text);
p.Format.Font.Size = 12; p.Format.Font.Bold = true;
p.Format.SpaceBefore = Unit.FromMillimeter(4); p.Format.SpaceAfter = Unit.FromMillimeter(1);
}
private static Table NewTable(Section s, double[] widthsCm)
{
var t = s.AddTable();
t.Borders.Width = 0.25; t.Borders.Color = Colors.LightGray;
foreach (var w in widthsCm) t.AddColumn(Unit.FromCentimeter(w));
return t;
}
private static void AddKeyValue(Table t, string key, string value)
{
var r = t.AddRow();
r.Cells[0].AddParagraph(key);
var vp = r.Cells[1].AddParagraph(value);
vp.Format.Alignment = ParagraphAlignment.Right;
}
private static void HeaderRow(Table t, params string[] cells)
{
var r = t.AddRow();
r.Shading.Color = Colors.WhiteSmoke;
for (int i = 0; i < cells.Length; i++)
{
var p = r.Cells[i].AddParagraph(cells[i]);
p.Format.Font.Bold = true;
}
}
private static void DataRow(Table t, params string[] cells)
{
var r = t.AddRow();
for (int i = 0; i < cells.Length; i++)
r.Cells[i].AddParagraph(cells[i] ?? string.Empty);
}
private static string Trim(string s, int max) =>
string.IsNullOrEmpty(s) ? string.Empty : (s.Length <= max ? s : s[..max] + "…");
private static string DataHash(PeriodStatement s, IReadOnlyList<LedgerEntry> entries)
{
string material = CsvExporter.Statement(s) + CsvExporter.Ledger(entries);
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(material));
return Convert.ToHexString(hash)[..16].ToLowerInvariant();
}
}
}
@@ -5,6 +5,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="PDFsharp-MigraDoc-GDI" Version="6.2.4" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.3" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@@ -30,6 +30,7 @@ namespace PolyTrader.Modules.Accounting.Ui
this.lblKpiVolume = new System.Windows.Forms.Label(); this.lblKpiVolume = new System.Windows.Forms.Label();
this.lblKpiTrades = new System.Windows.Forms.Label(); this.lblKpiTrades = new System.Windows.Forms.Label();
this.pnlOverviewTop = new System.Windows.Forms.Panel(); this.pnlOverviewTop = new System.Windows.Forms.Panel();
this.btnExportPdf = new System.Windows.Forms.Button();
this.btnExportCsv = new System.Windows.Forms.Button(); this.btnExportCsv = new System.Windows.Forms.Button();
this.btnCalc = new System.Windows.Forms.Button(); this.btnCalc = new System.Windows.Forms.Button();
this.cbCurrency = new System.Windows.Forms.ComboBox(); this.cbCurrency = new System.Windows.Forms.ComboBox();
@@ -233,6 +234,7 @@ namespace PolyTrader.Modules.Accounting.Ui
// //
// pnlOverviewTop // pnlOverviewTop
// //
this.pnlOverviewTop.Controls.Add(this.btnExportPdf);
this.pnlOverviewTop.Controls.Add(this.btnExportCsv); this.pnlOverviewTop.Controls.Add(this.btnExportCsv);
this.pnlOverviewTop.Controls.Add(this.btnCalc); this.pnlOverviewTop.Controls.Add(this.btnCalc);
this.pnlOverviewTop.Controls.Add(this.cbCurrency); this.pnlOverviewTop.Controls.Add(this.cbCurrency);
@@ -253,11 +255,20 @@ namespace PolyTrader.Modules.Accounting.Ui
// //
this.btnExportCsv.Location = new System.Drawing.Point(838, 5); this.btnExportCsv.Location = new System.Drawing.Point(838, 5);
this.btnExportCsv.Name = "btnExportCsv"; this.btnExportCsv.Name = "btnExportCsv";
this.btnExportCsv.Size = new System.Drawing.Size(110, 26); this.btnExportCsv.Size = new System.Drawing.Size(90, 26);
this.btnExportCsv.TabIndex = 9; this.btnExportCsv.TabIndex = 9;
this.btnExportCsv.Text = "CSV-Export"; this.btnExportCsv.Text = "CSV-Export";
this.btnExportCsv.UseVisualStyleBackColor = true; this.btnExportCsv.UseVisualStyleBackColor = true;
// //
// btnExportPdf
//
this.btnExportPdf.Location = new System.Drawing.Point(934, 5);
this.btnExportPdf.Name = "btnExportPdf";
this.btnExportPdf.Size = new System.Drawing.Size(90, 26);
this.btnExportPdf.TabIndex = 10;
this.btnExportPdf.Text = "PDF-Export";
this.btnExportPdf.UseVisualStyleBackColor = true;
//
// btnCalc // btnCalc
// //
this.btnCalc.Location = new System.Drawing.Point(732, 5); this.btnCalc.Location = new System.Drawing.Point(732, 5);
@@ -520,6 +531,7 @@ namespace PolyTrader.Modules.Accounting.Ui
private System.Windows.Forms.ComboBox cbCurrency; private System.Windows.Forms.ComboBox cbCurrency;
private System.Windows.Forms.Button btnCalc; private System.Windows.Forms.Button btnCalc;
private System.Windows.Forms.Button btnExportCsv; private System.Windows.Forms.Button btnExportCsv;
private System.Windows.Forms.Button btnExportPdf;
private System.Windows.Forms.FlowLayoutPanel flpKpis; private System.Windows.Forms.FlowLayoutPanel flpKpis;
private System.Windows.Forms.Label lblKpiNet; private System.Windows.Forms.Label lblKpiNet;
private System.Windows.Forms.Label lblKpiClosing; private System.Windows.Forms.Label lblKpiClosing;
@@ -33,6 +33,7 @@ namespace PolyTrader.Modules.Accounting.Ui
// Übersicht / BWA // Übersicht / BWA
btnCalc.Click += (_, _) => Recalculate(); btnCalc.Click += (_, _) => Recalculate();
btnExportCsv.Click += (_, _) => ExportCsv(); btnExportCsv.Click += (_, _) => ExportCsv();
btnExportPdf.Click += (_, _) => ExportPdf();
cbOvAccount.SelectedIndexChanged += (_, _) => Recalculate(); cbOvAccount.SelectedIndexChanged += (_, _) => Recalculate();
cbCurrency.SelectedIndexChanged += (_, _) => Recalculate(); cbCurrency.SelectedIndexChanged += (_, _) => Recalculate();
@@ -159,6 +160,38 @@ namespace PolyTrader.Modules.Accounting.Ui
} }
} }
private void ExportPdf()
{
if (_ledger == null || _report == null) return;
DateTime from = dtFrom.Value.Date;
DateTime to = dtTo.Value.Date.AddDays(1).AddTicks(-1);
int? accId = IdOf(cbOvAccount.SelectedItem);
using var dlg = new SaveFileDialog
{
Filter = "PDF-Datei (*.pdf)|*.pdf",
FileName = $"abrechnung_{(accId?.ToString() ?? "alle")}_{from:yyyyMMdd}-{dtTo.Value:yyyyMMdd}.pdf"
};
if (dlg.ShowDialog(this) != DialogResult.OK) return;
try
{
var statement = _report.BuildStatement(accId, from, to);
var monthly = _report.BuildMonthly(accId, from, to);
var entries = _ledger.Query(accId, from, to, 100000);
var cur = _report.ResolveCurrency(cbCurrency.SelectedItem?.ToString() ?? "USDC", to);
CurrencyContext view = cur.Available ? cur : new CurrencyContext("USDC", 1m, true, "Native Buchungswährung.");
byte[] pdf = PdfExporter.Render(statement, monthly, entries, view.Code, view.Factor, view.Note);
System.IO.File.WriteAllBytes(dlg.FileName, pdf);
lblAccStatus.Text = $"PDF exportiert: {dlg.FileName} ({entries.Count} Buchungen, {view.Code}).";
}
catch (Exception ex)
{
lblAccStatus.Text = $"PDF-Export fehlgeschlagen: {ex.Message}";
}
}
// ---------------- Ledger ---------------- // ---------------- Ledger ----------------
private void LoadLedger() private void LoadLedger()
@@ -127,5 +127,25 @@ namespace PolyTrader.Tests
Assert.Contains("Endsaldo,135", csv); Assert.Contains("Endsaldo,135", csv);
Assert.Contains("Netto-Handelsergebnis (Cash),35", csv); Assert.Contains("Netto-Handelsergebnis (Cash),35", csv);
} }
// ---------------- PdfExporter (MigraDoc-Rendering headless) ----------------
[Fact]
public void Pdf_render_produces_valid_pdf_bytes()
{
var s = new PeriodStatement(1, Jul, Jul, 100m, 135m, 200m, 50m, 230m, 0m, 5m, 2m, 35m, 2, 1);
var entries = new List<LedgerEntry>
{
new() { AccountId = 1, EventType = LedgerEventType.TradeSell, Timestamp = Jul,
MarketSlug = "market-x", Side = "SELL", Size = 10m, PriceUsdc = 0.5m,
GrossUsdc = 130m, FeeUsdc = 1m, NetUsdc = 129m, TxHash = "0xabc" }
};
byte[] pdf = PdfExporter.Render(s, new[] { s }, entries, "USDC", 1.0m, "Native Buchungswährung.");
Assert.True(pdf.Length > 500);
// PDF-Signatur %PDF-
Assert.Equal(new byte[] { 0x25, 0x50, 0x44, 0x46 }, pdf.Take(4).ToArray());
}
} }
} }