using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using PolyTrader.Modules.Accounting.Logic; using PolyTrader.Modules.Accounting.Models; using PolyTrader.Modules.Accounting.Persistence; using PolyTrader.Modules.Accounting.Services; using PolyTrader.Tests.TestSupport; using PolyTraderSharp; using PolyTraderSharp.Models; using PolyTraderSharp.Services; using Xunit; namespace PolyTrader.Tests { /// /// Sicherheitsnetz für das Accounting-Fundament (A-1): pure Klassifikation (Typ/Vorzeichen/ /// Idempotenz), Deposit/Withdrawal-Trennung intern↔extern, und die Ingest-Orchestrierung /// (idempotenter Upsert, Balance-Anker, Backfill vs. inkrementell). /// public class AccountingTests { // ---------------- Pure Klassifikation ---------------- private static RawActivity Act(string type, string side, decimal usdc, decimal fee = 0m, string tx = "0xabc", int logIndex = 0, string token = "tok") => new() { Type = type, Side = side, Timestamp = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc), TxHash = tx, LogIndex = logIndex, TokenId = token, UsdcAmount = usdc, Fee = fee, Size = 10m, Price = 0.5m }; [Fact] public void Buy_is_cash_out_including_fee() { var e = AccountingClassifier.ClassifyActivity(1, Act("TRADE", "BUY", 100m, 1.5m), 7); Assert.Equal(LedgerEventType.TradeBuy, e.EventType); Assert.Equal(-101.5m, e.NetUsdc); // −(brutto + fee) Assert.Equal(100m, e.GrossUsdc); Assert.Equal(1.5m, e.FeeUsdc); Assert.Equal(7, e.IngestBatchId); } [Fact] public void Sell_is_cash_in_minus_fee() { var e = AccountingClassifier.ClassifyActivity(1, Act("TRADE", "SELL", 100m, 1.5m), 7); Assert.Equal(LedgerEventType.TradeSell, e.EventType); Assert.Equal(98.5m, e.NetUsdc); // brutto − fee } [Theory] [InlineData("REDEEM", 50.0, 50.0)] [InlineData("REWARD", 3.0, 3.0)] [InlineData("SPLIT", 20.0, 0.0)] // geldneutral [InlineData("MERGE", 20.0, 0.0)] [InlineData("CONVERSION", 20.0, 0.0)] public void Other_types_have_expected_net(string type, double usdc, double expectedNet) { var e = AccountingClassifier.ClassifyActivity(1, Act(type, "", (decimal)usdc), 7); Assert.Equal((decimal)expectedNet, e.NetUsdc); } [Fact] public void Idempotency_key_is_stable_and_type_specific() { var buy = AccountingClassifier.ClassifyActivity(1, Act("TRADE", "BUY", 100m, tx: "0xAbC", logIndex: 2), 7); var buyAgain = AccountingClassifier.ClassifyActivity(1, Act("TRADE", "BUY", 100m, tx: "0xabc", logIndex: 2), 99); Assert.Equal(buy.IdempotencyKey, buyAgain.IdempotencyKey); // gleicher Key trotz anderem Batch/Casing var sell = AccountingClassifier.ClassifyActivity(1, Act("TRADE", "SELL", 100m, tx: "0xabc", logIndex: 2), 7); Assert.NotEqual(buy.IdempotencyKey, sell.IdempotencyKey); // Typ unterscheidet } [Fact] public void Transfer_external_is_deposit_or_withdrawal_internal_is_skipped() { var contracts = new HashSet(StringComparer.OrdinalIgnoreCase) { "0xctf" }; var deposit = AccountingClassifier.ClassifyTransfer(1, new RawTransfer { IsIncoming = true, FromAddress = "0xExternalWhale", ToAddress = "0xSafe", UsdcAmount = 500m, TxHash = "0xd", LogIndex = 1 }, 7, contracts); Assert.NotNull(deposit); Assert.Equal(LedgerEventType.Deposit, deposit!.EventType); Assert.Equal(500m, deposit.NetUsdc); var withdrawal = AccountingClassifier.ClassifyTransfer(1, new RawTransfer { IsIncoming = false, FromAddress = "0xSafe", ToAddress = "0xExternalBank", UsdcAmount = 200m, TxHash = "0xw", LogIndex = 0 }, 7, contracts); Assert.Equal(LedgerEventType.Withdrawal, withdrawal!.EventType); Assert.Equal(-200m, withdrawal.NetUsdc); // interne Bewegung (Gegenpart = System-Contract) → NICHT als Ein-/Auszahlung buchen var internalMove = AccountingClassifier.ClassifyTransfer(1, new RawTransfer { IsIncoming = false, FromAddress = "0xSafe", ToAddress = "0xCTF", UsdcAmount = 100m, TxHash = "0xi" }, 7, contracts); Assert.Null(internalMove); } // ---------------- Ingest-Orchestrierung ---------------- private sealed class FakeActivitySource : IActivitySource { public List Items { get; } = new(); public int Calls { get; private set; } public Task> GetActivityAsync(string wallet, DateTime? since, CancellationToken ct) { Calls++; return Task.FromResult((IReadOnlyList)Items.ToList()); } } private sealed class FakeBalance : IBalanceAnchorSource { public decimal? Value; public Task GetBalanceAsync(string wallet, CancellationToken ct) => Task.FromResult(Value); } private static (AccountingIngestService svc, EfLedgerRepository ledger, EfIngestRunRepository runs, FakeActivitySource act, FakeBalance bal) BuildIngest() { var factory = new InMemoryContextFactory(o => new AccountingDbContext(o)); var ledger = new EfLedgerRepository(factory); var runs = new EfIngestRunRepository(factory); var raw = new EfRawSnapshotRepository(factory); var act = new FakeActivitySource(); var bal = new FakeBalance(); var state = new TradingState(); var svc = new AccountingIngestService(state, ledger, runs, raw, act, new NullTransferSource(), bal, new AccountingSystemContracts(), new TerminalLogger()); return (svc, ledger, runs, act, bal); } private static AccountState LiveAccount(int id) => new() { AccountId = id, Name = "Live", IsDemo = false, WalletAddress = "0xSafe" }; [Fact] public async Task Ingest_books_entries_and_is_idempotent_on_reingest() { var (svc, ledger, _, act, bal) = BuildIngest(); act.Items.Add(Act("TRADE", "BUY", 100m, 1m, tx: "0x1", logIndex: 0)); act.Items.Add(Act("TRADE", "SELL", 120m, 1m, tx: "0x2", logIndex: 0)); bal.Value = 18m; // Anker var run1 = await svc.IngestAccountAsync(LiveAccount(1), backfill: true, CancellationToken.None); Assert.True(run1.Success); Assert.Equal(2, run1.NewEntries); Assert.Equal(0, run1.DuplicateEntries); Assert.Equal(2, ledger.Count(1)); // Zweiter Lauf mit denselben Ereignissen → alles Duplikate, keine Doppelbuchung. var run2 = await svc.IngestAccountAsync(LiveAccount(1), backfill: true, CancellationToken.None); Assert.Equal(0, run2.NewEntries); Assert.Equal(2, run2.DuplicateEntries); Assert.Equal(2, ledger.Count(1)); } [Fact] public async Task Ingest_records_balance_anchor_delta() { var (svc, ledger, _, act, bal) = BuildIngest(); act.Items.Add(Act("TRADE", "BUY", 100m, 0m, tx: "0x1")); // Netto −100 act.Items.Add(Act("REDEEM", "", 150m, tx: "0x2")); // Netto +150 → Σ = 50 bal.Value = 50m; // Anker == Ledger-Netto → Δ 0 var run = await svc.IngestAccountAsync(LiveAccount(1), backfill: true, CancellationToken.None); Assert.Equal(50m, run.LedgerNetUsdc); Assert.Equal(50m, run.BalanceAnchorUsdc); Assert.Equal(0m, run.BalanceDeltaUsdc); // Vollständigkeit: Soll-Ist ≈ 0 } [Fact] public async Task Incremental_run_queries_from_last_timestamp_with_lookback() { var (svc, _, _, act, _) = BuildIngest(); act.Items.Add(Act("TRADE", "BUY", 100m, tx: "0x1")); await svc.IngestAccountAsync(LiveAccount(1), backfill: true, CancellationToken.None); var run = await svc.IngestAccountAsync(LiveAccount(1), backfill: false, CancellationToken.None); // Fenster beginnt beim jüngsten Ledger-Zeitpunkt minus Lookback-Überlappung. Assert.NotNull(run.FromTimestamp); var expected = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc) .AddHours(-AccountingIngestService.IncrementalLookbackHours); Assert.Equal(expected, run.FromTimestamp); } } }