using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using PolyTrader.Modules.Accounting.Logic; using PolyTrader.Modules.Accounting.Models; using PolyTrader.Modules.Accounting.Persistence; using PolyTrader.Modules.Accounting.Services; using PolyTraderSharp; namespace PolyTrader.Web.Endpoints { public static class AccountingEndpoints { public static IEndpointRouteBuilder MapAccountingEndpoints(this IEndpointRouteBuilder routes) { var group = routes.MapGroup("/api/modules/accounting"); // 1. Periodenabrechnung (Statement) group.MapGet("/statement", ( IServiceProvider sp, int? accountId, DateTime? from, DateTime? to, string? currency) => { var reports = sp.GetService(); if (reports == null) return Results.StatusCode(503); int? accId = accountId.HasValue && accountId.Value > 0 ? accountId.Value : null; var now = DateTime.UtcNow; var fromDate = from ?? new DateTime(now.Year, now.Month, 1); var toDate = to ?? now.Date; string currCode = string.IsNullOrWhiteSpace(currency) ? "USDC" : currency.Trim().ToUpperInvariant(); var currCtx = reports.ResolveCurrency(currCode, toDate); var statement = reports.BuildStatement(accId, fromDate, toDate); var monthly = reports.BuildMonthly(accId, fromDate, toDate); decimal V(decimal usdc) => Math.Round(usdc * currCtx.Factor, 2, MidpointRounding.AwayFromZero); var monthlyRows = monthly.Select(m => new { from = m.From.ToString("yyyy-MM-dd"), to = m.To.ToString("yyyy-MM-dd"), monthLabel = m.From.ToString("MMMM yyyy"), openingBalance = V(m.OpeningBalanceUsdc), closingBalance = V(m.ClosingBalanceUsdc), balanceChange = V(m.BalanceChange), deposits = V(m.Deposits), withdrawals = V(m.Withdrawals), tradeVolume = V(m.TradeVolume), redeems = V(m.Redeems), rewards = V(m.Rewards), fees = V(m.Fees), netTradingResult = V(m.NetTradingResultUsdc), tradeCount = m.TradeCount, entryCount = m.EntryCount }).ToList(); return Results.Ok(new { currency = new { code = currCtx.Code, factor = currCtx.Factor, available = currCtx.Available, note = currCtx.Note, rateDate = currCtx.RateDate.HasValue ? currCtx.RateDate.Value.ToString("yyyy-MM-dd") : null, rateAgeDays = currCtx.RateAgeDays, isStale = currCtx.IsStale, warning = currCtx.Warning }, statement = new { accountId = statement.AccountId, from = statement.From.ToString("yyyy-MM-dd"), to = statement.To.ToString("yyyy-MM-dd"), openingBalance = V(statement.OpeningBalanceUsdc), closingBalance = V(statement.ClosingBalanceUsdc), balanceChange = V(statement.BalanceChange), deposits = V(statement.Deposits), withdrawals = V(statement.Withdrawals), tradeVolume = V(statement.TradeVolume), redeems = V(statement.Redeems), rewards = V(statement.Rewards), fees = V(statement.Fees), netTradingResult = V(statement.NetTradingResultUsdc), tradeCount = statement.TradeCount, entryCount = statement.EntryCount }, monthly = monthlyRows }); }); // 2. PDF-Export group.MapGet("/export/pdf", ( IServiceProvider sp, int? accountId, DateTime? from, DateTime? to, string? currency) => { var reports = sp.GetService(); var ledgerRepo = sp.GetService(); if (reports == null || ledgerRepo == null) return Results.StatusCode(503); int? accId = accountId.HasValue && accountId.Value > 0 ? accountId.Value : null; var now = DateTime.UtcNow; var fromDate = from ?? new DateTime(now.Year, now.Month, 1); var toDate = to ?? now.Date; string currCode = string.IsNullOrWhiteSpace(currency) ? "USDC" : currency.Trim().ToUpperInvariant(); var currCtx = reports.ResolveCurrency(currCode, toDate); // Ohne gueltigen Kurs ist der Umrechnungsfaktor 0 — jeder Betrag im Beleg waere // dann 0,00, ohne als Fehler erkennbar zu sein. Ein Buchhaltungsbeleg, den man // weiterreichen kann, darf so nicht entstehen (#533). if (!currCtx.Available) { return Results.BadRequest(new { error = $"Waehrung {currCtx.Code} ist nicht verfuegbar: {currCtx.Note} " + "Es wurde kein Dokument erzeugt.", currency = currCtx.Code, note = currCtx.Note }); } var statement = reports.BuildStatement(accId, fromDate, toDate); var monthly = reports.BuildMonthly(accId, fromDate, toDate); var periodEntries = ledgerRepo.Query(accId, fromDate, toDate, 20000); try { // Ein veralteter Kurs sperrt den Beleg nicht — er gehoert nur sichtbar darauf. string note = currCtx.IsStale ? currCtx.Note + " ACHTUNG: " + currCtx.Warning : currCtx.Note; byte[] pdfBytes = PdfExporter.Render( statement, monthly, periodEntries, currCtx.Code, currCtx.Factor, note); string fileName = $"Abrechnung_{currCtx.Code}_{fromDate:yyyyMMdd}_{toDate:yyyyMMdd}.pdf"; return Results.File(pdfBytes, "application/pdf", fileName); } catch (Exception ex) { return Results.BadRequest(new { error = $"PDF-Generierung fehlgeschlagen: {ex.Message}" }); } }); // 3. CSV-Export group.MapGet("/export/csv", ( IServiceProvider sp, int? accountId, DateTime? from, DateTime? to) => { var ledgerRepo = sp.GetService(); if (ledgerRepo == null) return Results.StatusCode(503); int? accId = accountId.HasValue && accountId.Value > 0 ? accountId.Value : null; var now = DateTime.UtcNow; var fromDate = from ?? new DateTime(now.Year, now.Month, 1); var toDate = to ?? now.Date; var entries = ledgerRepo.Query(accId, fromDate, toDate, 50000); string csv = CsvExporter.Ledger(entries); string fileName = $"Ledger_{fromDate:yyyyMMdd}_{toDate:yyyyMMdd}.csv"; return Results.File(Encoding.UTF8.GetBytes(csv), "text/csv", fileName); }); // 4. Ledger-Journal (Buchungssätze abfragen mit Paginierung & Suche) group.MapGet("/ledger", ( IServiceProvider sp, int? accountId, DateTime? from, DateTime? to, string? search, int? page, int? pageSize) => { var ledgerRepo = sp.GetService(); if (ledgerRepo == null) return Results.StatusCode(503); int? accId = accountId.HasValue && accountId.Value > 0 ? accountId.Value : null; var entries = ledgerRepo.Query(accId, from, to, 10000); var query = entries.AsEnumerable(); if (!string.IsNullOrWhiteSpace(search)) { string q = search.Trim(); query = query.Where(e => (e.MarketSlug != null && e.MarketSlug.Contains(q, StringComparison.OrdinalIgnoreCase)) || (e.Outcome != null && e.Outcome.Contains(q, StringComparison.OrdinalIgnoreCase)) || (e.TxHash != null && e.TxHash.Contains(q, StringComparison.OrdinalIgnoreCase)) || e.EventType.ToString().Contains(q, StringComparison.OrdinalIgnoreCase)); } var filtered = query.OrderByDescending(e => e.Timestamp).ToList(); int totalCount = filtered.Count; int curPage = Math.Max(1, page.GetValueOrDefault(1)); int curPageSize = Math.Clamp(pageSize.GetValueOrDefault(50), 10, 200); int totalPages = (int)Math.Ceiling(totalCount / (double)curPageSize); var items = filtered .Skip((curPage - 1) * curPageSize) .Take(curPageSize) .Select(e => new { timestamp = e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"), accountId = e.AccountId, eventType = e.EventType.ToString(), side = e.Side, marketSlug = e.MarketSlug, outcome = e.Outcome, size = e.Size, priceUsdc = e.PriceUsdc, grossUsdc = e.GrossUsdc, feeUsdc = e.FeeUsdc, netUsdc = e.NetUsdc, txHash = e.TxHash, source = e.Source }) .ToList(); return Results.Ok(new { totalCount, page = curPage, pageSize = curPageSize, totalPages, items }); }); // 5. Ingest-Läufe (Status) group.MapGet("/runs", (IServiceProvider sp, int? accountId, int? limit) => { var runRepo = sp.GetService(); List runs; try { runs = runRepo?.GetRecent(accountId, limit.GetValueOrDefault(50)) ?? new List(); } catch { runs = new List(); } var list = runs.Select(r => new { id = r.Id, accountId = r.AccountId, startedAt = r.StartedAt.ToString("yyyy-MM-dd HH:mm:ss"), completedAt = r.FinishedAt.HasValue ? r.FinishedAt.Value.ToString("yyyy-MM-dd HH:mm:ss") : "—", backfill = r.Backfill, newEntries = r.NewEntries, duplicateEntries = r.DuplicateEntries, balanceDeltaUsdc = r.BalanceDeltaUsdc, success = r.Success, message = r.Message }).ToList(); return Results.Ok(list); }); // 6. Ingest manuell auslösen group.MapPost("/ingest", async (IServiceProvider sp, AccountingIngestRequest? request, CancellationToken ct) => { var ingest = sp.GetService(); if (ingest == null) return Results.StatusCode(503); bool backfill = request?.Backfill ?? false; try { await ingest.IngestAllAsync(backfill, ct); return Results.Ok(new { success = true, backfill, message = "Ingest-Durchlauf erfolgreich abgeschlossen." }); } catch (Exception ex) { return Results.BadRequest(new { error = ex.Message }); } }); return routes; } public record AccountingIngestRequest(bool Backfill); } }