Behebt #532 und #533. #532 — Kopfleiste bricht um statt zu scrollen Mit elf Ansichten brauchte die Leiste 1623 px. Darunter scrollte sie seitlich weg, ohne dass ein Hinweis darauf sichtbar war: Wer einen rechten Tab anklickte, verlor "Dashboard" aus dem Blick und fand den Weg zurueck nicht mehr. Statt overflow-x nun flex-wrap auf Leiste und Kopfzeile. Geprueft von 1920 bis 768 px — nichts wird mehr abgeschnitten, der Koerper scrollt nie waagerecht. #533 — Kurse holen, statt EUR still auf null zu setzen Die Tabelle acc_fx_rates gab es seit A-2, befuellt hat sie nie jemand. Deshalb war EUR nicht verfuegbar, und der PDF-Export machte daraus wortlos einen Beleg mit lauter Nullbetraegen. Neu holt EcbFxRateSource die amtlichen Referenzkurse ueber die EZB-Daten-API (Reihe EXR/D.USD.EUR.SP00.A, CSV). FxRateSyncService stoesst das beim ersten Ingest-Lauf des Tages an: erstmalig 400 Tage rueckwirkend, danach nur noch der Rand. Die Kurse werden gespeichert, nicht bei Bedarf geholt — eine Abrechnung fuer einen vergangenen Monat muss den Kurs von damals nehmen und beim naechsten Aufruf dasselbe Ergebnis liefern. Zwei Fallen, die dabei zu umgehen waren: - Die Reihe liefert USD je EUR (1 EUR = 1,1652 USD), FxRate.UsdToEur meint die Gegenrichtung. Wer das verwechselt, bekommt eine Abrechnung, die um rund 35 % danebenliegt und trotzdem plausibel aussieht. Gespeichert wird der Kehrwert. - Die CSV-Antwort enthaelt in TITLE_COMPL ein Komma innerhalb von Anfuehrungszeichen; naives Splitten verschoebe die Spalten. Die Warnung erscheint, sobald der verwendete Kurs mehr als drei Tage vor dem Stichtag liegt. Drei Tage, weil genau so gross die planmaessige Wochenendluecke ist: Der Freitagskurs gilt bis Montag und ist dabei kein Mangel. Der Beleg bleibt trotzdem erzeugbar — die Warnung steht sichtbar darauf und im WebUI ueber der Abrechnung. Nur ohne jeden Kurs lehnt der Export jetzt mit 400 ab, statt Nullbetraege auszugeben. PDF und CSV laufen deshalb nicht mehr ueber window.open: Eine nackte JSON-Fehlermeldung in einem neuen Reiter ist keine Antwort. downloadExport() holt die Datei, meldet Fehler im Klartext und stoesst sonst den Download an. Nebenbei im Testlauf gefunden und behoben: Der manuelle Anstoss aus dem WebUI und die stuendliche Schleife trafen sich in derselben Sekunde — im Log standen "280 Kurse uebernommen" und "53 Kurse uebernommen" untereinander, beide hatten die EZB gefragt. SyncDailyAsync serialisiert jetzt und prueft den Tag ein zweites Mal innerhalb der Sperre. Geprueft auf der Linux-Test-VM gegen die echte EZB-API: 280 Kurse vom 06.08.2025 bis 09.09.2026 uebernommen, Umrechnung stimmt (1/1,1652 = 0,858222), eine Maerz-Abrechnung nimmt den Kurs vom 31.03. und nicht den heutigen, das Warnbanner erscheint ab dem vierten Tag, der Export verweigert ohne Kurs und liefert sonst ein gueltiges PDF. 551 von 551 Tests bestanden (18 neue). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
303 lines
13 KiB
C#
303 lines
13 KiB
C#
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<AccountingReportService>();
|
|
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<AccountingReportService>();
|
|
var ledgerRepo = sp.GetService<ILedgerRepository>();
|
|
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<ILedgerRepository>();
|
|
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<ILedgerRepository>();
|
|
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<IIngestRunRepository>();
|
|
List<IngestRun> runs;
|
|
try
|
|
{
|
|
runs = runRepo?.GetRecent(accountId, limit.GetValueOrDefault(50)) ?? new List<IngestRun>();
|
|
}
|
|
catch
|
|
{
|
|
runs = new List<IngestRun>();
|
|
}
|
|
|
|
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<AccountingIngestService>();
|
|
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);
|
|
}
|
|
}
|