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:
co-authored by
Claude Opus 4.8
parent
42a599a3a3
commit
ca0d4ceed0
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user