Accounting A-2: neutrale Periodenabrechnung + BWA-Uebersicht + FX + CSV-Export

Alles laenderneutral, ohne steuerliche Einordnung (A-3 US-Steuerschicht bleibt bewusst offen,
haengt an den CPA-Fragebogen-Antworten). Konzept: docs/konzepte/KONZEPT-Modul-Accounting.md, A-2.

Pure Logik (unit-getestet):
- AccountingEngine.BuildStatement: aggregiert Ledger-Saetze eines Zeitraums (x Account/alle) zu
  Anfangs-/Endsaldo, Ein-/Auszahlungen, Handelsvolumen, Redeems, Rewards, Fees und
  Netto-Handelsergebnis (Cash-Basis, EXKL. Ein-/Auszahlungen); Invariante Endsaldo-Anfang =
  Ergebnis + Einz. - Ausz. BuildMonthlyBreakdown: Monatsvergleich mit verketteten Anfangssalden.
- FxConverter: USDC->USD (dokumentierte 1:1-Annahme) + USD->EUR ueber amtliche Tageskurse
  (acc_fx_rates, Nearest-on-or-before fuer Wochenend-/Feiertage).
- CsvExporter: Ledger + Statement als RFC-4180-CSV, kulturinvariant (Punkt-Dezimal, ISO-Datum).

Infrastruktur:
- acc_fx_rates (FxRate, PK Datum) + EfFxRateRepository (Upsert je Datum). Migration AddFxRates angewendet.
- ILedgerRepository.GetUpTo (alle Saetze <= to fuer die Abrechnung inkl. Anfangssaldo).
- AccountingReportService: baut Abrechnungen + Waehrungs-View (USDC/USD immer, EUR wenn Kurse geladen).

UI (designerfaehig, partial + .Designer.cs): neuer erster Tab 'Uebersicht / BWA' mit KPI-Kacheln
(Netto-Handelsergebnis gruen/rot, Endsaldo, Ein-/Auszahlungen, Fees, Rewards, Volumen, #Trades),
Monatsvergleich-Grid, Zeitraum-Picker (Standard laufender Monat), Konto- und Waehrungswahl, CSV-Export
via SaveFileDialog.

Tests: +6 (Periodenaggregation+Invariante, Monatsverkettung, FX-Umrechnung/Nearest-Kurs, CSV-Quoting).
Build 0 Fehler, 385 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:06:13 +02:00
co-authored by Claude Opus 4.8
parent a3c145c0ed
commit 42a599a3a3
15 changed files with 1205 additions and 10 deletions
@@ -0,0 +1,61 @@
using System;
using System.Collections.Generic;
using PolyTrader.Modules.Accounting.Logic;
using PolyTrader.Modules.Accounting.Models;
using PolyTrader.Modules.Accounting.Persistence;
namespace PolyTrader.Modules.Accounting.Services
{
/// <summary>
/// Anzeige-Währung einer Abrechnung. USDC/USD sind immer verfügbar (USD über die dokumentierte
/// 1:1-Annahme), EUR nur, wenn ein amtlicher Tageskurs geladen ist.
/// </summary>
public sealed record CurrencyContext(string Code, decimal Factor, bool Available, string Note);
/// <summary>
/// Report-Service (A-2): baut neutrale Periodenabrechnungen aus dem Ledger und stellt die
/// Währungs-Umrechnung (USDC/USD/EUR) bereit. Reine Aggregation liegt im <see cref="AccountingEngine"/>,
/// FX im <see cref="FxConverter"/> der Service verdrahtet nur Persistenz + pure Logik.
/// </summary>
public class AccountingReportService
{
private readonly ILedgerRepository _ledger;
private readonly IFxRateRepository _fx;
/// <summary>USDC→USD-Faktor (dokumentierte 1:1-Annahme; Zielland ggf. über Settings umstellbar).</summary>
public decimal UsdcToUsd { get; set; } = FxConverter.DefaultUsdcToUsd;
public AccountingReportService(ILedgerRepository ledger, IFxRateRepository fx)
{
_ledger = ledger;
_fx = fx;
}
public PeriodStatement BuildStatement(int? accountId, DateTime from, DateTime to) =>
AccountingEngine.BuildStatement(_ledger.GetUpTo(accountId, to), from, to, accountId);
public List<PeriodStatement> BuildMonthly(int? accountId, DateTime from, DateTime to) =>
AccountingEngine.BuildMonthlyBreakdown(_ledger.GetUpTo(accountId, to), from, to, accountId);
/// <summary>Löst die Anzeige-Währung zum Stichtag auf (für EUR wird der EZB-Kurs am/vor dem Datum genutzt).</summary>
public CurrencyContext ResolveCurrency(string code, DateTime asOf)
{
switch ((code ?? "USDC").Trim().ToUpperInvariant())
{
case "USD":
return new CurrencyContext("USD", UsdcToUsd, true, $"USDC≈USD (1:1-Annahme, Faktor {UsdcToUsd}).");
case "EUR":
var rate = FxConverter.NearestOnOrBefore(_fx.GetAll(), asOf);
if (rate == null)
return new CurrencyContext("EUR", 0m, false, "Keine EZB-Tageskurse geladen EUR nicht verfügbar.");
return new CurrencyContext("EUR", UsdcToUsd * rate.UsdToEur, true,
$"USD→EUR {rate.UsdToEur} ({rate.Date:yyyy-MM-dd}, {rate.Source}); USDC≈USD 1:1.");
default:
return new CurrencyContext("USDC", 1.0m, true, "Native Buchungswährung.");
}
}
public static decimal Convert(decimal usdc, CurrencyContext ctx) =>
Math.Round(usdc * ctx.Factor, 2, MidpointRounding.AwayFromZero);
}
}