using IBKRTrader.Core.Logging; using IBKRTrader.Modules.Accounting.Logic; using IBKRTrader.Modules.Accounting.Persistence; using IBKRTrader.Modules.Accounting.Services; namespace IBKRTrader.UI.Views.Modules; /// /// Fenster des Accounting-Moduls: Übersicht/BWA, Ledger, Steuer (Platzhalter), Abrechnung/Export, /// Abruf/Status. Alle DB-Zugriffe laufen NUR auf Nutzer-Interaktion (nicht im Konstruktor) – so /// konstruiert der Smoke-UI-Check das Fenster auch ohne DB fehlerfrei. /// public sealed class AccountingMainForm : Form { private readonly ILedgerRepository _ledger; private readonly IIngestRunRepository _runs; private readonly AccountingReportService _report; private readonly AccountingIngestService _ingest; private readonly LoggingService _logger; private readonly DateTimePicker _from = new() { Format = DateTimePickerFormat.Short, Width = 110 }; private readonly DateTimePicker _to = new() { Format = DateTimePickerFormat.Short, Width = 110 }; private readonly ComboBox _account = new() { DropDownStyle = ComboBoxStyle.DropDown, Width = 140 }; private readonly ComboBox _currency = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 80 }; private readonly Label _kpis = new() { AutoSize = true, Location = new Point(12, 8) }; private readonly DataGridView _monthly = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill }; private readonly DataGridView _ledgerGrid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill }; private readonly DataGridView _runsGrid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill }; private readonly Label _status = new() { AutoSize = true, ForeColor = SystemColors.GrayText, Location = new Point(12, 8) }; public AccountingMainForm( ILedgerRepository ledger, IIngestRunRepository runs, AccountingReportService report, AccountingIngestService ingest, LoggingService logger) { _ledger = ledger; _runs = runs; _report = report; _ingest = ingest; _logger = logger; Text = "Accounting"; Width = 1000; Height = 680; StartPosition = FormStartPosition.CenterScreen; MinimumSize = new Size(760, 480); _from.Value = DateTime.Today.AddMonths(-1); _to.Value = DateTime.Today; _currency.Items.AddRange(new object[] { "USD", "EUR" }); _currency.SelectedIndex = 0; BuildLayout(); } private void BuildLayout() { var tabs = new TabControl { Dock = DockStyle.Fill }; // ── gemeinsame Filterleiste ── var filter = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 40, Padding = new Padding(8, 6, 8, 0) }; filter.Controls.Add(new Label { Text = "Von", AutoSize = true, Margin = new Padding(0, 8, 4, 0) }); filter.Controls.Add(_from); filter.Controls.Add(new Label { Text = "Bis", AutoSize = true, Margin = new Padding(8, 8, 4, 0) }); filter.Controls.Add(_to); filter.Controls.Add(new Label { Text = "Konto", AutoSize = true, Margin = new Padding(8, 8, 4, 0) }); filter.Controls.Add(_account); filter.Controls.Add(new Label { Text = "Währung", AutoSize = true, Margin = new Padding(8, 8, 4, 0) }); filter.Controls.Add(_currency); var btnRefresh = new Button { Text = "Aktualisieren", Width = 120, Margin = new Padding(12, 3, 0, 0) }; btnRefresh.Click += (_, _) => RefreshAll(); filter.Controls.Add(btnRefresh); // ── Tab: Übersicht/BWA ── var tabOverview = new TabPage("Übersicht / BWA"); _monthly.Top = 90; var overviewPanel = new Panel { Dock = DockStyle.Fill }; overviewPanel.Controls.Add(_monthly); var kpiPanel = new Panel { Dock = DockStyle.Top, Height = 84 }; kpiPanel.Controls.Add(_kpis); overviewPanel.Controls.Add(kpiPanel); tabOverview.Controls.Add(overviewPanel); // ── Tab: Ledger ── var tabLedger = new TabPage("Ledger"); tabLedger.Controls.Add(_ledgerGrid); // ── Tab: Steuer (Platzhalter) ── var tabTax = new TabPage("Steuer"); tabTax.Controls.Add(new Label { Dock = DockStyle.Fill, Padding = new Padding(16), Text = "Steuerliche Einordnung ist noch offen (Jurisdiktion nicht festgelegt).\n\n" + "Der neutrale Ledger und die Periodenabrechnung sind davon unabhängig gültig.\n" + "Eine konkrete Steuerschicht (z. B. DE-Kapitalertragsteuer oder US Form 8949 / Schedule D)\n" + "wird hier später als klar dokumentierte, prüfbare Rechenschicht ergänzt.\n\n" + "Hinweis: Dies ist keine Steuerberatung." }); // ── Tab: Abrechnung / Export ── var tabExport = new TabPage("Abrechnung / Export"); var exportPanel = new FlowLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(16), FlowDirection = FlowDirection.TopDown }; exportPanel.Controls.Add(new Label { AutoSize = true, Text = "Exportiert die aktuelle Auswahl (Zeitraum / Konto / Währung):" }); var btnCsvLedger = new Button { Text = "Ledger als CSV…", Width = 180, Margin = new Padding(0, 8, 0, 0) }; btnCsvLedger.Click += (_, _) => ExportCsvLedger(); var btnCsvStmt = new Button { Text = "Abrechnung als CSV…", Width = 180, Margin = new Padding(0, 8, 0, 0) }; btnCsvStmt.Click += (_, _) => ExportCsvStatement(); var btnPdf = new Button { Text = "Abrechnung als PDF…", Width = 180, Margin = new Padding(0, 8, 0, 0) }; btnPdf.Click += (_, _) => ExportPdf(); exportPanel.Controls.Add(btnCsvLedger); exportPanel.Controls.Add(btnCsvStmt); exportPanel.Controls.Add(btnPdf); tabExport.Controls.Add(exportPanel); // ── Tab: Abruf / Status ── var tabIngest = new TabPage("Abruf / Status"); var ingestButtons = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 40, Padding = new Padding(8, 6, 8, 0) }; var btnIncr = new Button { Text = "Inkrementell abrufen", Width = 160 }; btnIncr.Click += async (_, _) => await RunIngest(backfill: false); var btnBackfill = new Button { Text = "Backfill (voll)", Width = 140, Margin = new Padding(8, 0, 0, 0) }; btnBackfill.Click += async (_, _) => await RunIngest(backfill: true); ingestButtons.Controls.Add(btnIncr); ingestButtons.Controls.Add(btnBackfill); var statusPanel = new Panel { Dock = DockStyle.Top, Height = 40 }; statusPanel.Controls.Add(_status); _status.Text = "Offline-Standard: keine Live-Quelle registriert → der Ingest bucht nichts (korrekt)."; tabIngest.Controls.Add(_runsGrid); tabIngest.Controls.Add(statusPanel); tabIngest.Controls.Add(ingestButtons); tabs.TabPages.AddRange(new[] { tabOverview, tabLedger, tabTax, tabExport, tabIngest }); Controls.Add(tabs); Controls.Add(filter); } // ── Daten laden (nur auf Interaktion) ── private string? SelectedAccount() { var text = _account.Text?.Trim(); return string.IsNullOrWhiteSpace(text) || text == "(alle)" ? null : text; } private void RefreshAll() { try { LoadAccounts(); LoadOverview(); LoadLedger(); LoadRuns(); } catch (Exception ex) { _logger.Error("Accounting", $"UI-Refresh fehlgeschlagen: {ex.Message}", ex); MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning); } } private void LoadAccounts() { var current = _account.Text; _account.Items.Clear(); _account.Items.Add("(alle)"); foreach (var a in _ledger.DistinctAccounts()) _account.Items.Add(a); _account.Text = string.IsNullOrEmpty(current) ? "(alle)" : current; } private void LoadOverview() { DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1); var stmt = _report.BuildStatement(SelectedAccount(), from, to); var view = _report.GetCurrencyView(_currency.Text, to); decimal C(decimal v) => Math.Round(v * view.Factor, 2); _kpis.Text = $"Netto-Handelsergebnis: {C(stmt.NetTradingResult):N2} {view.Code} " + $"Handelsvolumen: {C(stmt.TradeVolume):N2} Dividenden: {C(stmt.Dividends):N2} Fees: {C(stmt.Fees):N2}\n" + $"Einzahlungen: {C(stmt.Deposits):N2} Auszahlungen: {C(stmt.Withdrawals):N2} " + $"Endsaldo: {C(stmt.ClosingBalance):N2} Trades: {stmt.TradeCount} Buchungen: {stmt.EntryCount}\n" + $"{view.Note}"; var monthly = _report.BuildMonthly(SelectedAccount(), from, to) .Select(m => new { Monat = m.From.ToString("yyyy-MM"), Anfang = C(m.OpeningBalance), Einzahlungen = C(m.Deposits), Auszahlungen = C(m.Withdrawals), Volumen = C(m.TradeVolume), Fees = C(m.Fees), Ergebnis = C(m.NetTradingResult), Endsaldo = C(m.ClosingBalance) }).ToList(); _monthly.DataSource = monthly; } private void LoadLedger() { DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1); var rows = _ledger.Query(SelectedAccount(), from, to, 2000) .Select(e => new { Zeit = e.Timestamp, e.AccountId, Typ = e.EventType.ToString(), e.Side, e.Symbol, e.Currency, e.Quantity, Preis = e.PriceNative, Brutto = e.GrossBase, Fee = e.FeeBase, Netto = e.NetBase, e.TransactionId }).ToList(); _ledgerGrid.DataSource = rows; } private void LoadRuns() { var rows = _runs.GetRecent(SelectedAccount(), 100) .Select(r => new { r.AccountId, Start = r.StartedAt, Ende = r.FinishedAt, r.Backfill, Neu = r.NewEntries, Duplikate = r.DuplicateEntries, r.Success, Anker = r.BalanceAnchorBase, LedgerNetto = r.LedgerNetBase, Delta = r.BalanceDeltaBase, r.Message }).ToList(); _runsGrid.DataSource = rows; } // ── Export ── private void ExportCsvLedger() { DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1); var entries = _ledger.Query(SelectedAccount(), from, to, 100000); SaveText("ledger.csv", "CSV|*.csv", CsvExporter.Ledger(entries)); } private void ExportCsvStatement() { DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1); var stmt = _report.BuildStatement(SelectedAccount(), from, to); SaveText("abrechnung.csv", "CSV|*.csv", CsvExporter.Statement(stmt)); } private void ExportPdf() { try { DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1); var account = SelectedAccount(); var stmt = _report.BuildStatement(account, from, to); var monthly = _report.BuildMonthly(account, from, to); var entries = _ledger.Query(account, from, to, 100000).OrderBy(e => e.Timestamp).ToList(); var view = _report.GetCurrencyView(_currency.Text, to); byte[] pdf = PdfExporter.Render(stmt, monthly, entries, view.Code, view.Factor, view.Note); using var dlg = new SaveFileDialog { FileName = "abrechnung.pdf", Filter = "PDF|*.pdf" }; if (dlg.ShowDialog(this) == DialogResult.OK) { File.WriteAllBytes(dlg.FileName, pdf); _logger.Info("Accounting", $"PDF-Abrechnung geschrieben: {dlg.FileName}"); } } catch (Exception ex) { _logger.Error("Accounting", $"PDF-Export fehlgeschlagen: {ex.Message}", ex); MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning); } } private void SaveText(string suggested, string filter, string content) { try { using var dlg = new SaveFileDialog { FileName = suggested, Filter = filter }; if (dlg.ShowDialog(this) == DialogResult.OK) { File.WriteAllText(dlg.FileName, content); _logger.Info("Accounting", $"Export geschrieben: {dlg.FileName}"); } } catch (Exception ex) { _logger.Error("Accounting", $"Export fehlgeschlagen: {ex.Message}", ex); MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning); } } // ── Ingest ── private async Task RunIngest(bool backfill) { try { _status.Text = backfill ? "Backfill läuft…" : "Inkrementeller Abruf läuft…"; await _ingest.IngestAllAsync(backfill, CancellationToken.None); _status.Text = $"Abruf abgeschlossen ({DateTime.Now:HH:mm:ss})."; LoadRuns(); } catch (Exception ex) { _status.Text = $"Fehler: {ex.Message}"; _logger.Error("Accounting", $"Manueller Ingest fehlgeschlagen: {ex.Message}", ex); } } }