diff --git a/src/PolyTrader.Modules.Accounting/Logic/PdfExporter.cs b/src/PolyTrader.Modules.Accounting/Logic/PdfExporter.cs
new file mode 100644
index 0000000..7b2e533
--- /dev/null
+++ b/src/PolyTrader.Modules.Accounting/Logic/PdfExporter.cs
@@ -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
+{
+ ///
+ /// 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.
+ ///
+ public static class PdfExporter
+ {
+ public static byte[] Render(
+ PeriodStatement statement,
+ IReadOnlyList monthly,
+ IReadOnlyList 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 entries)
+ {
+ string material = CsvExporter.Statement(s) + CsvExporter.Ledger(entries);
+ byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(material));
+ return Convert.ToHexString(hash)[..16].ToLowerInvariant();
+ }
+ }
+}
diff --git a/src/PolyTrader.Modules.Accounting/PolyTrader.Modules.Accounting.csproj b/src/PolyTrader.Modules.Accounting/PolyTrader.Modules.Accounting.csproj
index 08ba0f0..cf0c4a4 100644
--- a/src/PolyTrader.Modules.Accounting/PolyTrader.Modules.Accounting.csproj
+++ b/src/PolyTrader.Modules.Accounting/PolyTrader.Modules.Accounting.csproj
@@ -5,6 +5,7 @@
+
all
diff --git a/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.Designer.cs b/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.Designer.cs
index 5b44557..ea463aa 100644
--- a/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.Designer.cs
+++ b/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.Designer.cs
@@ -30,6 +30,7 @@ namespace PolyTrader.Modules.Accounting.Ui
this.lblKpiVolume = new System.Windows.Forms.Label();
this.lblKpiTrades = new System.Windows.Forms.Label();
this.pnlOverviewTop = new System.Windows.Forms.Panel();
+ this.btnExportPdf = new System.Windows.Forms.Button();
this.btnExportCsv = new System.Windows.Forms.Button();
this.btnCalc = new System.Windows.Forms.Button();
this.cbCurrency = new System.Windows.Forms.ComboBox();
@@ -233,6 +234,7 @@ namespace PolyTrader.Modules.Accounting.Ui
//
// pnlOverviewTop
//
+ this.pnlOverviewTop.Controls.Add(this.btnExportPdf);
this.pnlOverviewTop.Controls.Add(this.btnExportCsv);
this.pnlOverviewTop.Controls.Add(this.btnCalc);
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.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.Text = "CSV-Export";
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
//
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.Button btnCalc;
private System.Windows.Forms.Button btnExportCsv;
+ private System.Windows.Forms.Button btnExportPdf;
private System.Windows.Forms.FlowLayoutPanel flpKpis;
private System.Windows.Forms.Label lblKpiNet;
private System.Windows.Forms.Label lblKpiClosing;
diff --git a/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.cs b/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.cs
index b22b641..be2b479 100644
--- a/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.cs
+++ b/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.cs
@@ -33,6 +33,7 @@ namespace PolyTrader.Modules.Accounting.Ui
// Übersicht / BWA
btnCalc.Click += (_, _) => Recalculate();
btnExportCsv.Click += (_, _) => ExportCsv();
+ btnExportPdf.Click += (_, _) => ExportPdf();
cbOvAccount.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 ----------------
private void LoadLedger()
diff --git a/tests/PolyTrader.Tests/AccountingReportTests.cs b/tests/PolyTrader.Tests/AccountingReportTests.cs
index 8e699a6..98713bf 100644
--- a/tests/PolyTrader.Tests/AccountingReportTests.cs
+++ b/tests/PolyTrader.Tests/AccountingReportTests.cs
@@ -127,5 +127,25 @@ namespace PolyTrader.Tests
Assert.Contains("Endsaldo,135", 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
+ {
+ 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());
+ }
}
}