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()); + } } }